init: 클린 아키텍처 백엔드

This commit is contained in:
DongHyeonka
2026-07-24 14:29:36 +09:00
parent 9eed16d097
commit 821fe00c32
971 changed files with 74769 additions and 1 deletions
+39
View File
@@ -0,0 +1,39 @@
# app-bootstrap — application entry point & composition root
## Registered identity
- Module ID: `app-bootstrap`
- Gradle path: `:app-bootstrap`
- Focused test: `./gradlew :app-bootstrap:test --console=plain`
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `.harness/project/modules.yaml`.
Package root: `dev.caskeleton.bootstrap`.
## Responsibility
- Spring Boot entrypoint (`CaSkeletonApplication`).
- Runtime settings and logging bootstrap.
- Final cross-module wiring.
- Architecture tests that inspect all modules.
## Allowed
- Runtime leaves explicitly allowed by `.harness/project/modules.yaml`; do not duplicate the
19-leaf dependency list here.
- Spring Boot startup/runtime dependencies.
- ArchUnit in tests.
## Forbidden
- Concrete business policy.
- Controllers, use cases, repository adapters, or mappers authored directly under bootstrap.
- Settings classes that implement business logic.
## Test
```bash
cd src
./gradlew :app-bootstrap:test
./gradlew verifyCleanArchitectureDependencies
```
+831
View File
@@ -0,0 +1,831 @@
# app-bootstrap — 설계 결정 참조
애플리케이션 진입점이자 합성 루트(composition root) 모듈. 패키지 루트: `dev.caskeleton.bootstrap`.
이 모듈은 비즈니스 로직을 담지 않는다. Spring Boot 기동, 런타임 설정 바인딩, 모듈 간 최종
와이어링, 그리고 모든 모듈을 검사하는 아키텍처 테스트만 둔다. 허용/금지 의존, 책임 범위, 테스트
명령 같은 **모듈 규칙**의 SSOT 는 [CLAUDE.md](CLAUDE.md) 다.
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
"왜 이렇게 했나"가 궁금할 때 본다. 본문은 한국어로 쓰고, 클래스·Spring API·메트릭 이름처럼
꼭 필요한 기술 용어만 영어로 남긴다. 별도 추적 ID를 몰라도 읽히도록 결정의 배경과
트레이드오프를 문장으로 풀어 둔다.
## 목차
- [runtime/startup — Fail-fast 시작 파이프라인](#runtimestartup--fail-fast-시작-파이프라인과-시작-실패-분류)
- [runtime — 시크릿 소스 선택과 런타임 안전 가드](#runtime--시크릿-소스-선택과-런타임-안전-가드)
- [concurrency — 도메인 컨텍스트 전파](#concurrency--도메인-컨텍스트-전파-전략-조립)
- [async — 비동기 executor 컨텍스트 전파와 포화 처리](#async--비동기-executor-컨텍스트-전파와-포화-처리)
- [idempotency — 멱등성 런타임 조립과 TTL 상한](#idempotency--멱등성-런타임-조립과-ttl-상한)
- [lock — 분산 락 메트릭 데코레이터](#lock--분산-락-메트릭-데코레이터-배선)
- [outbox — 트랜잭셔널 아웃박스 릴레이](#outbox--트랜잭셔널-아웃박스-릴레이-와이어링)
- [logging — 로그 시크릿 마스킹·샘플링·가명화](#logging--로그-시크릿-마스킹샘플링가명화)
- [metrics — 메트릭 계약 MeterFilter](#metrics--메트릭-계약-meterfilter-설치)
- [management/security — 액추에이터 엔드포인트 보안](#managementsecurity--액추에이터-엔드포인트-보안-체인)
- [tracing — 분산 트레이싱 wiring과 샘플링 정책](#tracing--분산-트레이싱-wiring과-샘플링-정책)
- [settings — @ConfigurationProperties 검증 정책](#settings--configurationproperties-검증-정책)
- [build.gradle — 의존성 구성 근거](#buildgradle--의존성-구성-근거)
---
## runtime/startup — Fail-fast 시작 파이프라인과 시작 실패 분류
잘못 설정된 채로 기동돼 트래픽을 받는 것보다, 기동 시점에 명확한 이유와 함께 멈추는 편이 안전하다.
이 패키지는 그 "빨리·명확하게 실패시키기(fail-fast)"를 담당한다.
### FlywayProdSafetyValidator
- **`prod` 프로파일에서 Flyway 안전장치가 꺼지지 못하도록 런타임에서 강제한다.** Flyway 옵션은
`application.yml`에 안전값으로 고정돼 있지만(`baseline-on-migrate=false`, `out-of-order=false`,
`clean-disabled=true`), 환경별 override 가 프로덕션에서 이 값을 조용히 되돌릴 수 있다. 이 검증기는
그런 override 를 시작 시점에 잡아 `PROFILE_MISMATCH`(exit 71)로 부팅을 막는다.
- **금지하는 옵션은 각각 "복구 불가능한 위험" 때문이다.** `baseline-on-migrate=true`는 누락된
마이그레이션을 감지하는 안전망을 없애고, `out-of-order=true`는 개발자 간 마이그레이션 적용 순서
일관성을 깨며, `clean-disabled=false`는 스키마 전체를 드롭하는 파괴적 `clean` 명령을 다시
활성화한다.
- **프로파일 비교는 대소문자를 무시한다.** `SPRING_PROFILES_ACTIVE=PROD`처럼 대문자 오타가 나도
가드가 우회되지 않게 하기 위함이다.
- **Flyway repair 는 가드 대상이 아니다.** repair 는 property 가 아니라 Flyway 명령이고 스켈레톤은
repair 호출 경로 자체를 배선하지 않으므로, "prod 에서 repair 금지"는 런타임 트리거가 없는
문서화된 정책으로 남는다. 부분 스키마 복구는 in-prod Flyway repair 가 아니라
runbook(`runbook://migration/manual-recovery`)을 통한 수동 복구로 가도록 실패 메시지에 명시한다.
(프로젝트가 repair 경로를 추가한다면 동일하게 prod 게이트 + non-prod 감사 로그를 걸어야 한다.)
### RequiredEnvironmentValidator
- **datasource 연결 환경변수를 마이그레이션 실행 전에 검증해 부팅을 빨리 실패시킨다.** Flyway
forward-only 마이그레이션은 context refresh 동안 애플리케이션 datasource 에 대해 실행된다. 연결
환경변수가 빠져 있으면 마이그레이션은 시작 단계 구분자 없이 불투명한 driver/connection 에러로
한참 뒤에야 실패한다. 이 검증기는 그것을 명시적인 env 검증 실패(`STARTUP_VALIDATION_FAILED`,
exit 78)로 바꿔, 누락된 운영자용 env 키를 전부 이름으로 짚어준다.
- **검증 범위를 datasource 3종(url/username/driver)으로 의도적으로 좁혔다.** 이것들이 이 브랜치가
소유하는 마이그레이션 전제조건이기 때문이다. app-name(`BootstrapSettings`), issuer-uri(security
settings) 등 다른 필수값은 각 모듈의 가드가 책임지므로 여기서 중복 검증하지 않는다.
- **값이 없거나(blank) 공백이면 모두 "누락"으로 본다.** spring-dotenv 에서 미설정 placeholder
`${...}`가 빈 문자열로 해석되기 때문에, null 뿐 아니라 blank 도 누락으로 처리해야 빈틈이 없다.
- **메시지는 env 키 이름순으로 정렬해 출력한다.** Map 순회 순서와 무관하게 결정적이고 안정적인 실패
메시지를 보장하기 위함이다.
### MigrationStartupConfig
- **시작 마이그레이션 가드와 Flyway 마이그레이션 전략을 한곳에 모아 소유권을 명확히 한다.** prod
토글 / 멀티 인스턴스 가드는 env-driven `RuntimeSafetyConfig`가 소유하고, 이 config 는 마이그레이션
관련 가드만 배선한다.
- **`migrationStartupRunner` 빈 이름은 반드시 유지해야 한다.** 멀티 인스턴스 모드
(`APP_MULTI_INSTANCE_ENABLED=true`)에서 `StartupSafetyValidator`가 이 정확한 이름의 빈 존재를
단언하기 때문이다. 동시에 이 빈은 `FlywayMigrationStrategy`로서 Spring Boot Flyway
오토컨피규레이션이 `migrate()`를 위임하는 전략이 된다.
### MigrationStartupRunner
- **`FlywayMigrationStrategy`로 등록해 context refresh 중에 마이그레이션을 직접 실행한다(오토컨피그
기본값을 그대로 두지 않는다).** refresh 안에서 실행한다는 것은 마이그레이션이 완료되거나 부팅을
실패시키는 일이 애플리케이션이 readiness 를 보고하기 *전에* 일어난다는 뜻이다. 따라서 절반만
마이그레이션된 스키마가 트래픽을 받는 일이 구조적으로 없다(actuator readiness probe 의 형태
자체는 health-lifecycle 브랜치가 소유하고, 여기서는 "마이그레이션 → ready" 순서만 책임진다).
- **`FlywayException``MigrationFailedException`(exit 70)으로 번역한다.** 일반적이고 구분 안 되는
스택트레이스 대신 구조화된 `MIGRATION_FAILED` 로그와 표준 exit 코드를 남기기 위함이다.
- **멀티 인스턴스에서는 in-app 마이그레이션 + Flyway 자체 schema-history 락에 의존하는 것이 기본
전략이다.** 여러 레플리카의 동시 `migrate()`를 Flyway 의 락이 직렬화한다. 대안인 "in-app
마이그레이션을 끄고 플랫폼 one-shot Job 으로 돌리기"는 "migration=Job"을 강제하는 공개 표준이
없는 배포 선택이라 스켈레톤 기본값이 아니다. Job 으로 돌리는 프로젝트는 이 빈의 `migrate()`
no-op 으로 만들면 된다.
### 시작 실패 타입(StartupErrorCode / StartupPhase / StartupFailures / *Exception)
- **`StartupErrorCode` — 네 가지 시작 실패 원인을 (레지스트리 error code, 표준 프로세스 exit 코드,
`StartupPhase`)로 묶는다.** exit 78(env 누락/오류)과 70(마이그레이션 실패)은 sysexits 표준
(`EX_CONFIG`, `EX_SOFTWARE`)과 정렬돼 외부적으로 방어 가능하다. 반면 exit 71(프로파일 불일치)과
72(필수 어댑터 비활성)는 ca-tmpl 내부 규약이다 — sysexits 의 `EX_OSERR`/`EX_OSFILE`이 이 의미와
맞지 않아, 외부 표준이 아니라 내부 룩업 테이블 값으로 정했다. exit 코드는 carrying 예외가
`ExitCodeGenerator`를 구현하기 때문에만 JVM 종료 상태가 된다.
- **`StartupPhase` — 실패 원인을 운영자가 로그에서 구분할 수 있게 하는 `startup.phase` 필드다.**
시작 실패는 요청이 아직 없으므로 HTTP 에러 응답에 절대 실리지 않는다. 따라서 구조화 로그의
`startup.phase``kubectl logs`/`describe`에서 네 가지 원인을 가려내는 유일한 계약이다. wire
이름은 dash-case(`env-validation | migration | adapter-enablement | profile-check`)이며, MDC 키가
아니라 Logstash structured argument 로 방출되므로 snake_case MDC 키 레지스트리 규칙은 적용되지
않는다.
- **`StartupFailures` — 시작 실패를 던지는 단일 출처로, 예외 생성 *전에* 구조화 로그를 먼저
방출한다.** "throw 직전 각 validator 가 직접 logger 를 호출"하는 패턴을 중앙화해, 어떤 시작 가드도
원인/구분자 없는 실패를 던지지 못하게 한다. 로그는 `startup.phase` / `error.code` /
`error.category` 세 필드를 Logstash structured argument 로 싣고, cause 가 있으면
`error.root_cause.class` / `error.root_cause.message` 요약 필드만 추가한다. 원본 cause 는 반환되는
`StartupFailureException`에 보존하지만 SLF4J throwable 인자로 넘기지 않는다. 따라서 canonical
startup-failure 로그는 운영자가 분류할 수 있는 요약만 남기고 프레임워크/드라이버 stacktrace 를
출력하지 않는다.
- **`StartupFailureException` — 네 실패 원인의 베이스 타입이며 `ExitCodeGenerator`로 exit 코드를 JVM
종료 상태로 만든다.** context refresh 가 실패하면 `SpringApplication``SpringBootExceptionHandler`
(부팅 스레드의 uncaught-exception handler 로 설치됨)가 실패 예외에서 `getExitCode()`를 읽어
`System.exit(code)`를 호출한다 — `main()` 수정이 필요 없다. `IllegalStateException`을 상속하는
이유는 과거 `StartupSafetyValidator``IllegalStateException`을 던졌기 때문으로, 타입드 예외로
업그레이드한 뒤에도 소스/동작 호환을 유지하기 위함이다.
- **`StartupFailureExceptionReporter` / `StartupFailureSpringBootLogFilter` — Spring Boot 의 중복
startup stacktrace 를 억제한다.** reporter 는 failure cause chain 에 `StartupFailureException`
있을 때만 `true`를 반환해 Boot 의 generic `Application run failed` 출력을 "이미 보고됨"으로
처리한다. 이후 Boot 가 반쯤 초기화된 context 를 닫다가 `Unable to close ApplicationContext`를 다시
남길 수 있으므로, canonical startup-failure 로그가 이미 찍힌 프로세스에서는 해당
`org.springframework.boot.SpringApplication` 중복 메시지만 logback turbo filter 가 차단한다. 일반
startup failure 가 아닌 예외와 다른 logger/message 는 기존 Spring Boot 로그 경로를 유지한다.
- **`RequiredAdapterDisabledException`은 런타임-라이프사이클의 `ADAPTER_DISABLED`와 의도적으로
구분된다.** 이쪽은 *시작* 검증(필수 어댑터/조정 빈이 꺼져 있음)이고, 후자는 비활성 옵션 어댑터에
대한 *런타임* 호출이다.
---
## runtime — 시크릿 소스 선택과 런타임 안전 가드
시크릿을 어디서 읽을지(env / Vault / 클라우드 시크릿 매니저)를 인터페이스 뒤로 숨기고, 위험한
런타임 설정값은 기동 시점에 막는다.
### SecretSource
- **시크릿 해석을 인터페이스 한 겹 뒤로 숨긴 backend seam 이다.** 시크릿이 필요한 코드는 이
인터페이스에만 의존하고, 실제 백엔드(env / Vault / AWS Secrets Manager / GCP Secret Manager)는
`SecretSourceFactory`가 설정값으로 고른다. `RateLimiter` / `RateLimiterFactory`와 같은 패턴이다.
백엔드를 추가하는 비용이 "새 `SecretSource` 구현 1개 + `SecretSourceStrategy` enum 값 1개 +
factory case 1개"로 고정되고, 소비자(`SecretSourceValidator`, 향후 어댑터)는 전혀 손대지 않는다.
- **빈 문자열은 "없음"으로 취급한다.** `resolve`가 blank 값을 `Optional.empty()`로 돌려주지 않으면,
"값은 존재하지만 비어 있는" 시크릿이 존재 검사(presence check)를 통과해 버려 prod 부팅을 막을 수
없게 된다.
### SecretSourceStrategy
- **enum 값 하나가 곧 백엔드 하나라는 확장 규약을 못 박는다.** `ca-skeleton.secret-source.strategy`
바인딩되며 `ENVIRONMENT`만 기본 제공한다. 미래 백엔드(VAULT, AWS_SECRETS_MANAGER,
GCP_SECRET_MANAGER)는 주석으로만 남겨 두어, 추가 절차가 "enum 값 + `SecretSource` 구현 + factory
case"임을 코드에서 바로 읽히게 했다(rate-limit algorithm enum 패턴과 동일).
### SecretSourceSettings
- **설정이 없어도 동작하는 기본값(ENVIRONMENT)을 박았다.** 스켈레톤은 별도 설정 없이 바로 부팅돼야
하므로, `strategy``null`이면 `ENVIRONMENT`로 보정한다. 백엔드 교체는 코드 수정이 아니라 설정
한 줄로 끝난다.
### SecretSourceConfig
- **시크릿 백엔드와 그 시작 가드를 composition root 에서만 와이어링한다.** `SecretSourceFactory`
선택된 `SecretSource`(기본 `ENVIRONMENT`)와, 그것을 통해 필수 시크릿을 검증하는
`SecretSourceValidator`를 빈으로 노출한다. 검증기를 Spring 스테레오타입 없는 일반 클래스로 둔
이유는 단위 테스트 가능성을 지키기 위함이고, 이 config 가 유일한 production 와이어링이다.
- **`RuntimeSafetyConfig`와 일부러 분리했다.** 시크릿 계약은 secrets-config-source-contract 가
소유하고, 런타임 안전 토글은 env-driven-runtime-configuration 이 소유한다. 소유 경계를 코드 구조로
보존하기 위해 한 config 로 합치지 않았다.
### SecretSourceFactory
- **유일한 확장 지점을 `switch` 하나로 모았다.** 새 백엔드는 `SecretSourceStrategy` 값 +
`SecretSource` 구현 + 이 `switch`의 case 추가로 끝나고 소비자는 바뀌지 않는다.
`RateLimiterFactory`와 같은 형태로, "확장 비용이 어디에 있는가"를 한곳에서 보이게 했다.
### EnvironmentSecretSource
- **기본 백엔드는 Spring `Environment`에서 읽는 것이다.** `ENVIRONMENT` 전략은 시크릿이 env var /
마운트 파일 / `application.yml`로 들어와 Spring 자체 property resolution 으로 바인딩됨을 전제한다.
Vault·AWS·GCP 연동은 소비자를 건드리지 않고 `SecretSourceFactory` 뒤에 별도 `SecretSource`
구현으로 추가하면 된다.
### SecretSourceValidator
- **prod 에서 깨지면 부팅 자체를 거부하는 fail-fast 가드다.** 두 가지 prod 불변식을 context refresh
완료 전에 강제한다. (1) `__LOCAL_DEV_` 접두사를 가진 dev/local 가짜 자격증명 값이 prod 프로파일에
도달하는 것은 잘못 로드된 사고이므로 부팅을 중단시킨다. (2) 레지스트리에서 `secret` +
`prod_default: null`로 분류된 모든 키는 prod 에서 비어 있지 않게 주입돼야 한다 — 빈 시크릿으로
부팅하는 것은 금지다. dev/local 프로파일은 두 검사를 건너뛴다(`__LOCAL_DEV_*`가 의도된 로컬
폴백이므로).
- **`SmartInitializingSingleton` 타이밍을 의도적으로 받아들였다.** 이 콜백은 모든 싱글톤 생성 **
context refresh 완료 *전*에 한 번 실행된다. 따라서 eager `DataSource`가 이 가드보다 먼저
`__LOCAL_DEV_` 자격증명으로 연결을 시도할 가능성은 있다. 그러나 prod 에서 `__LOCAL_DEV_` 값에
도달하는 것 자체가 예외적 오적재이고, 트래픽을 받기 전에 context 가 중단되므로 이 타이밍으로
충분하다고 판단했다. 더 이른 차단이 필요해지면 `EnvironmentPostProcessor`로 승격한다.
- **필수 시크릿 검사는 Spring 환경이 아니라 `SecretSource` 백엔드를 통해 한다.** 존재 검사를
백엔드(`secretSource.resolve(key)`) 경유로 하기 때문에, env → Vault/AWS 백엔드 교체 시에도 검사가
자동으로 따라간다. 검사를 Spring `Environment`에 고정했다면 백엔드를 바꿀 때 검증 로직이 함께
끊겼을 것이다.
- **`REQUIRED_PROD_SECRETS`는 레지스트리와 1:1로 강제된다.** 이 in-code 상수는
`docs/registries/secrets-classification.yaml`의 secret 행과 정확히 일치해야 하며,
`SecretsClassificationRegistryTest`가 1:1 일치를 단언한다. 그래서 레지스트리에 시크릿을 추가/제거하고
이 상수를 갱신하지 않으면 빌드가 깨진다 — drift 를 컴파일·테스트 단계에서 잡는다.
- **prod 판정은 대소문자 무시다.** `SPRING_PROFILES_ACTIVE=PROD` 같은 배포 오타도 prod 가드를
발동시켜야 하므로 `equalsIgnoreCase`로 비교한다(`Environment#matchesProfiles`는 대소문자를 구분해
"PROD"를 통과시킨다).
### HikariPoolConstraintValidator
- **HikariCP 노브 사이의 상호 제약을 부팅 실패로 바꾼다.** Hikari/Tomcat 은 잘못된 값을 결국
거부하지만 pool/connector 초기화 시점에 난해한 메시지로만 거부한다. 이 검증기는 계약을 일찍·명확
하게 만든다: resolved Spring property 를 읽고, 위반을 모두 모아 운영자가 실제로 설정한 `APP_*`
환경 키를 메시지에 담아 `IllegalStateException`을 던진다. 속성이 *없으면* 건너뛰고(placeholder/
프레임워크 기본값이 소유), *있을 때만* 검사한다.
- **강제하는 제약과 그 숫자의 근거.** `connection-timeout >= 250ms`(너무 짧으면 정상 연결도 타임
아웃). `validation-timeout < connection-timeout`(둘 다 있을 때; validation-timeout 기본 5000ms 가
흔한 connection-timeout 5s/5000ms 와 같아지는 충돌을 해소). `keepalive-time < max-lifetime`(둘 다
있을 때; keepalive 가 lifetime 보다 길면 의미 없음). `leak-detection-threshold`는 0(비활성)이
아니라면 `>= 2000ms`(너무 작으면 정상 사용을 누수로 오탐).
- **모든 노브를 `String`으로 읽어 직접 파싱하는 방어적 처리.** `env-keys.yaml``connection-timeout`
기본값은 Duration 문자열 `5s`인데 `src/.env``30000`(ms)을 준다. `Environment#getProperty(...,
Long.class)``"5s"`에 호출하면 `ConversionFailedException`이 난다. 그래서 각 값을 `String`으로
읽어 `parseMillis`로 넘기고, `null`/blank 또는 plain-integer 가 아닌 값은 `null`(= 부재로 간주,
조용히 skip)로 처리한다. 덕분에 검증기가 형식 drift 값에 절대 죽지 않는다.
- **env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다.** `connection-timeout` /
`max-lifetime``env-keys.yaml`에 등록돼 있고, greenfield 노브(validation-timeout, keepalive-time,
leak-detection-threshold)는 env 키가 없다. 없는 키 이름을 지어내는 대신 pending 문구를 메시지에 넣는다.
### OpenInViewSafetyValidator
- **OSIV(Open Session In View) OFF 를 hard stop 으로 강제한다.** OSIV 가 켜져 있으면 Hibernate
세션이 view 렌더링까지 열려 있어, presentation 레이어에서 lazy 연관을 건드리면 거기서 조용히 DB
쿼리가 나간다 — 이것이 정확히 금지된 레이어 경계 위반이다. Spring Boot 는
`spring.jpa.open-in-view`가 암묵 기본값일 때 WARN 만 찍을 뿐 명시적으로 `true`로 설정한 배포는
막지 못한다. WARN 은 놓치기 쉬우므로, resolved 값이 `true`면 운영자가 실제 설정한
`APP_DATASOURCE_OPEN_IN_VIEW` 키를 메시지에 담아 부팅을 실패시킨다.
- **읽기만 하고 re-bind 하지 않으며, 부재 값은 Spring 기본에 맡긴다.** `SmartInitializingSingleton`
으로 한 번만 검사하고, 값이 *없으면* Spring Boot 기본(이 스켈레톤은 `application.yml`에서 OSIV off
가 기본)에 맡기며 *있는 `true`*만 거부한다.
### RuntimeNumericBoundsValidator
- **고위험 숫자 노브(pool/thread 사이징)만 일부러 좁게 검증한다.** pool/connector 사이징 키는
Spring-native property(`spring.datasource.hikari.*`, `server.tomcat.*`)로 직결되고 `env-keys.yaml`
각각을 `positive_int` / `non_negative_int`로 표시하지만, 이 표시는 그동안 강제되지 않았다 —
Hikari/Tomcat 이 결국 거부하더라도 난해한 메시지로 init 시점에야 거부했다. 이 검증기는 resolved
Spring property 를 읽어, 운영자가 실제 설정한 `APP_*` 키를 담은 메시지로 일찍 부팅을 실패시킨다.
잘못 설정하면 실제 런타임 장애로 이어지는 키(pool/thread 사이징)에만 범위를 한정했고, Logback
소유 로그 사이징·Spring `Duration` 키는 (해당 라이브러리가 검증하므로) 범위 밖이다.
- **읽기 전용으로 Spring 바인딩을 중복하지 않고, 부재 키는 skip 한다.** 값이 없으면 placeholder/
프레임워크 기본값이 소유하므로 건너뛰고, 범위를 벗어난 *있는* 값만 거부한다("lenient default
금지").
### StartupSafetyValidator
- **`SmartInitializingSingleton` 타이밍을 고른 이유가 핵심이다.** 검사는 모든 싱글톤 생성 후 context
refresh 완료 전에 한 번 돈다. `EnvironmentPostProcessor`는 bean 정의가 생기기 전에 실행돼 bean
*존재 여부*(multi-instance 검사에 필요)를 검사할 수 없고, `ApplicationReadyEvent` 리스너는 트래픽
직전에야 발동해 잘못된 부팅을 거부하기엔 너무 늦다. 그래서 이 중간 타이밍을 택했고, 위반 시
throw 하여 context 가 시작을 거부한다.
- **prod 안전 토글은 잘못 켜져 있으면 PROFILE_MISMATCH(exit 71)로 죽인다.** prod 프로파일에서 내부
상세 노출/요청 본문 캡처 토글이 켜져 있으면 구조화된 startup-failure 로그와 함께 부팅을 중단한다.
- **multi-instance 가 켜지면 조율 bean 이 모두 존재해야 한다.** `APP_MULTI_INSTANCE_ENABLED=true`
`REQUIRED_MULTI_INSTANCE_BEANS`(distributed lock / cache stampede protection / outbox leader
election / distributed rate limiter / migration startup job)가 하나라도 빠지면
REQUIRED_ADAPTER_DISABLED(exit 72)로 실패시킨다. 이 bean 들은 각각 다른 브랜치가 소유하고, 이
검증기는 존재 여부만 단언한다.
- **prod 판정은 대소문자 무시다.** `SPRING_PROFILES_ACTIVE=PROD` 오타도 prod-safety 가드를
발동시켜야 하므로 `equalsIgnoreCase`로 비교한다.
---
## concurrency — 도메인 컨텍스트 전파 전략 조립
### DomainContextConfig
- **`DomainContextPropagator`를 빈으로 노출하고, 설정에서 고른 `DomainContextStrategy`
`DomainContextPropagatorFactory`를 통해 조립한다.** `application-core`는 설정을 직접 읽지 않는다는
원칙 때문에, 전략 해석과 propagator 생성은 composition root(여기)에서 끝내고 완성된 propagator 만
주입한다. 그래서 use case 코드는 어떤 전파 전략을 쓰는지 몰라도 되고, 전략 교체는 코드가 아니라
설정으로만 일어난다.
### DomainContextSettings
- **`ca-skeleton.domain-context.strategy`가 비어 있으면 `THREAD_LOCAL`로 기본값을 채운다.** 스켈레톤은
"설정 없이도 바로 동작하는 기본값"을 제공하는 것을 원칙으로 하므로, 전략을 강제로 지정하게 만들지
않고 가장 안전한 `THREAD_LOCAL`을 디폴트로 둔다. 이 전략은 rate-limit 알고리즘처럼 전용 환경변수
를 두지 않는데, 보안 비밀이나 배포별 값이 아니라 운영상의 선택지라 설정 바인딩만으로 충분하기
때문이다.
---
## async — 비동기 executor 컨텍스트 전파와 포화 처리
### AsyncContextTaskDecorator
- **executor 경계를 넘을 때 caller 스레드의 컨텍스트를 worker 스레드로 옮기는 단 하나의
`TaskDecorator`다.** MDC 는 submit 시점에 맵 전체를 복사한다(`MDC.getCopyOfContextMap()`). 그래서
async 전파 대상 4개 foundation 키(`request_id`/`trace_id`/`correlation_id`/`tenant_id`)와
`span_id`(SLF4J-Micrometer tracing bridge 가 MDC 에 써넣는 값)가 worker 로그 라인에 그대로
따라온다. 특정 상수 holder(`MdcKeys`)에 결합하지 않고 맵을 통째로 복사하는 이유는, 그래야 그
시점에 존재하는 모든 키를 빠짐없이 옮길 수 있기 때문이다.
- **domain 컨텍스트는 직접 복사하지 않고 shared seam(`DomainContextPropagator.wrap`)에 위임한다.** 이
브랜치는 executor 배선만 소유하고, caller→worker 의 실제 hand-off 는 seam 이 소유한다는 책임 분리에
따른 것이다.
- **trace/span "문자열"만 옮기지 Micrometer `Observation` scope 는 worker 스레드에서 다시 열지
않는다.** 그래서 로그 연속성은 유지되지만, worker 에서 새로 만든 child observation 은 caller 의
span 아래로 nesting 되지 않는다. 완전한 scope 전파는 `io.micrometer:context-propagation` + Spring
`ContextPropagatingTaskDecorator`가 필요한데, 그 라이브러리를 의도적으로 classpath 에 두지
않았다. 따라서 "수동 4-key copy decorator" 경로를 채택하고, worker 스레드 span 이 정말 필요한
프로젝트를 위해 라이브러리 업그레이드를 확장 지점(seam)으로 문서화해 둔다.
- **`SecurityContext` principal 은 일부러 전파하지 않는다.** `user_principal`은 전파 대상이 아니고,
pooled 로 재사용되는 worker 스레드에 `SecurityContext`를 복사하는 것은 stale-context 위험이기
때문이다. principal 이 worker 에서 진짜 필요한 use case 는 자기 executor 를 Spring Security 의
`DelegatingSecurityContextTaskExecutor`로 감싸서 명시적으로 opt-in 한다.
- **decorator 는 대칭적이다.** 태스크 실행 후 worker 스레드의 이전 MDC 를 복원하므로, pooled
스레드가 한 태스크의 컨텍스트를 다음 태스크로 흘리지 않는다.
### AsyncExecutorConfig
- **Spring Boot 가 auto-config 하는 `applicationTaskExecutor`를 bounded pool 로 교체한다.** Boot 기본
executor 의 큐는 unbounded(`Integer.MAX_VALUE`)라 금지된다. 빈 이름을 `applicationTaskExecutor`
두는 이유는 Boot 의 `@ConditionalOnMissingBean(Executor.class)` auto-config 를 back-off 시키면서,
동시에 `@Async`가 resolve 하는 바로 그 executor 가 되게 하기 위함이다.
- **TaskDecorator 미설정 executor 등록은 ApplicationContext 기동 실패로 만든다.**
`AsyncContextTaskDecorator`를 executor 빈의 *필수* 생성자 의존으로 두었기 때문에, decorator 없이
executor 를 등록하려 하면 컨텍스트가 뜨지 않는다.
- **`awaitTerminationSeconds(19)`는 컨테이너 app-shutdown 예산 20s 에서 cleanup margin 1s 를 뺀
값이다.** 25s 는 금지된다 — 컨테이너가 강제 종료하기 전에 graceful drain 이 끝나야 하기 때문이다.
- **`executor.saturation` gauge 는 살아있는 큐 깊이를 읽는다.** 큐 > 용량 80% 면 p2, rejection
발생이면 p1 알림 기준이다.
### AsyncExecutorSettings
- **큐는 반드시 bounded 여야 한다.** unbounded 큐는 `maxPoolSize`를 도달 불가능하게 만든다 — JDK
`ThreadPoolExecutor`는 큐가 가득 찼을 때만 core 이상으로 스레드를 늘리기 때문이다. 게다가 Spring
`ThreadPoolTaskExecutor`의 큐 기본값은 `Integer.MAX_VALUE`, 즉 사실상 unbounded 다. 그래서 compact
생성자가 큐 용량으로 `Integer.MAX_VALUE`를 거부한다 — 그만큼 큰 용량은 이름만 다른 unbounded
sentinel 이기 때문이다.
- **검증은 fail-fast(ApplicationContext 기동 실패)다.** 잘못 설정된 pool 은 런타임 fault 가 아니라
deploy-time 버그이므로, 런타임까지 끌고 가지 않고 기동 시점에 즉시 터뜨린다.
- **cross-field 불변식: `maxSize``coreSize`보다 작을 수 없다.** core 보다 작은 max 는 무의미하고,
pool 이 설정된 core 크기에조차 도달하지 못하게 만들기 때문이다.
### BackgroundJobMetrics
- **이 브랜치가 소유하는 background-job/async-executor 메트릭 어휘 SSOT 다.** 메트릭 이름·태그는
`docs/registries/metrics.yaml`과 verbatim 일치한다: `executor.saturation`(gauge, `executor_name`),
`executor.rejected.total`(counter, `executor_name`+`policy`), `job.retry.total`(counter,
`job_name`+`outcome`), `job.dlq.total`(counter, `job_name`).
- **`executor.*` 두 meter 는 `AsyncExecutorConfig`가 라이브로 배선하고, `job.*` 두 recorder 는 어휘
표면만 먼저 출시한다.** retry/DLQ 어휘는 outbox/outbound 브랜치가 나중에 retry carrier 를 고르면
소비하라고 이 브랜치가 소유한다. carrier 자체는 아직 정해지지 않았으므로, retry 엔진이 아니라
vocabulary surface 만 출시하는 것이다.
- **`MeterRegistry` 빈이 없으면(=classpath 에 Actuator 없음) 모든 연산이 no-op 다.** `ObjectProvider`
registry 를 resolve 하며, 이는 `OutboxMetrics` 선례를 그대로 따른다.
- **`RetryOutcome` enum 은 retry 어휘를 type-safe 하게 표기한 것이다.** `job.retry.total``outcome`
태그 값은 metrics.yaml 에서 4개(SUCCESS/RETRY/EXHAUSTED/DLQ)로 bounded 되어 있고, 미래의 retry
carrier 가 이 값을 emit 한다.
### LoggingAbortPolicy
- **기본 포화 정책은 `AbortPolicy`다.** bounded 큐가 가득 차고 pool 이 `maxPoolSize`에 도달하면
태스크를 거부하고 그 거부를 caller 에게 전달한다. 이 wrapper 는 bare
`ThreadPoolExecutor.AbortPolicy`가 빠뜨리는 두 가지 계약 의무를, abort 를 다시 던지기 *전에*
추가한다: (1) `error.code=JOB_EXECUTOR_REJECTED` + category + `executor_name` + `policy`를 담은
structured ERROR 로그, (2) `executor.rejected.total{executor_name, policy}` counter 증가.
- **그 다음 `RejectedExecutionException`을 던져 AbortPolicy 의미를 유지한다.** fire-and-forget
`@Async` caller 의 거부가 조용히 삼켜지지 않도록 하기 위함이다 — 이 예외가 async-exception 계약이
흡수하는 신호다.
- **허용되는 유일한 대안은 `CallerRunsPolicy`뿐이고, 그것도 명시적 use-case 선언이 있을 때만이다.**
caller-runs 의 back-pressure 는 request 스레드 latency 를 갉아먹기 때문이다. unbounded 큐는 아예
금지다.
---
## idempotency — 멱등성 런타임 조립과 TTL 상한
### IdempotencyConfig
- **공유 `Clock` 빈과 `IdempotencyExecutor`를 composition root 에서 조립하고, `@EnableScheduling`으로
만료 레코드 reaper 의 스케줄 purge 를 켠다.** `Clock`을 빈으로 한 번만 정의해 executor·persistence
store 어댑터·reaper·rate-limit 인터셉터가 같은 시계를 쓰게 만들어 테스트에서 시간 고정이 쉽고
분기마다 시간 해석이 어긋나지 않는다. executor 를 여기서 만드는 이유는 TTL 같은 설정을
`application-core`가 직접 읽으면 안 되기 때문이다. `@Scheduled` purge 는 `@EnableScheduling` 없이는
동작하지 않으므로 이 설정 클래스에서 명시적으로 활성화한다.
### IdempotencySettings
- **TTL 기본값은 24h 이고 상한은 72h 이며, 이 상한 검사는 컴팩트 생성자에서 fail-fast 로 던진다.**
`Duration`의 상한은 JSR-303(`@Valid`) 애너테이션으로 표현할 수 없는 교차 필드 불변식이라 생성자에서
직접 검증한다. 검증을 느슨하게 두고 넘어가면 과도하게 긴 TTL 이 저장소를 조용히 부풀리고 멱등성 키
추측 공격의 유효 시간 창을 넓히기 때문에, 부팅 시점에 즉시 실패시키는 쪽을 택했다.
- **`MAX_TTL`(72h) / 기본 TTL(24h)을 바꿀 때는 보안 베이스라인의 JWT 키 회전 겹침(overlap) 윈도와
반드시 함께 검토해야 한다.** 멱등성 레코드의 수명이 키 회전 겹침 윈도(현재 24h)보다 길면, 가명화
(pseudonym) 기준이 회전된 principal 에 대해 레코드가 재생(replay)될 수 있다. 이 불변식을 강제하는
CI 게이트는 security-operational-baseline 쪽에 위임돼 있다.
- **`reaperInterval`은 전용 환경변수가 없고 기본 10분이다.** reaper 실행 주기는 비밀이나 배포별 값이
아니라 순수 운영 튜닝 값이라 env key 를 따로 두지 않고, 비어 있거나 0/음수면 10분으로 채운다.
---
## lock — 분산 락 메트릭 데코레이터 배선
### DistributedLockConfig
- **`distributedLockProvider` 빈은 `ca-skeleton.runtime.multi-instance-enabled=true`일 때만
`@ConditionalOnProperty`로 등록되고 `@Primary`로 우선 적용된다.** 다중 인스턴스 환경에서만
`adapter-persistence`의 원시 `jdbcDistributedLock`(JdbcLockRegistry, Flyway 가 프로비저닝한
`INT_LOCK` 기반) 어댑터를 `MeteredDistributedLockPort`로 감싸 `lock.acquisition` 카운터를 기록한다.
- **단일 인스턴스에서는 이 조건부 빈이 아예 없으므로 `adapter-persistence`가 등록한 `@Primary
inProcessDistributedLock`(in-process `DefaultLockRegistry`)이 그대로 선택된다.** 이 경로는 의도적
으로 계측하지 않는다 — `lock.acquisition` 카운터는 인스턴스 간 분산 조율을 관측할 때만 의미가
있고, JVM 안의 단순 뮤텍스에는 무의미하기 때문이다.
- **빈 이름 `"distributedLockProvider"`는 `StartupSafetyValidator`가 이름으로 조회해 검증하는
계약이다.** 절대 이름을 바꾸지 말 것.
### MeteredDistributedLockPort
- **`DistributedLockPort`를 감싸는 얇은 Micrometer 데코레이터다.** `tryAcquire` 호출마다
`lock.acquisition` 카운터를 `outcome` 태그와 함께 1 증가시킨다. 태그 값은 세 가지: `acquired`(락
획득 성공, 위임 핸들 반환), `timeout`(`LockAcquisitionTimeoutException` 발생), `error`(그 외 모든
`RuntimeException` 발생). 메트릭 이름과 태그 값은 `docs/registries/metrics.yaml`의 `lock.acquisition`
행과 일치해야 한다.
- **리스 만료(lease-expiry) CME 흡수.** 보유자가 `close()`를 호출하기 전에 리스(저장소 TTL)가
만료되면 그 락 행은 이미 다른 인스턴스가 회수해 갔을 수 있고, 그 시점에 내부 `JdbcLock.unlock()`은
`ConcurrentModificationException`(CME)을 던진다. 이 예외를 호출자의 `finally { lock.close(); }`
블록 밖으로 그대로 전파하면 보호 구간(critical section)에서 발생한 본래 예외를 가려버린다
(masking). 그래서 CME 만 잡아 WARN 로그를 남기고 태그 없는 `lock.lease.expired` 카운터를 1
증가시킨 뒤 `close()`는 정상 복귀시킨다. 이렇게 해야 신호(로그+메트릭)는 남기면서도 호출자의 정상
흐름과 예외 전파를 방해하지 않는다. 오직 `ConcurrentModificationException`만 흡수하며, DB 장애 같은
다른 예외(예: `DataAccessResourceFailureException`)는 리스 만료 신호가 아니므로 손대지 않고 그대로
전파한다.
- **MeterRegistry 부재 시 no-op.** 레지스트리를 `ObjectProvider.getIfAvailable()`로 해석하므로
클래스패스에 `MeterRegistry` 빈이 없으면(Actuator 미탑재) 모든 메트릭 연산이 조용히 no-op 이
된다. `BackgroundJobMetrics` 선례와 동일한 패턴이다.
- **메트릭 실패가 락 경로를 절대 깨뜨리지 않음.** 카운터 등록·증가는 모두 try/catch 로 감싸 예외를
로깅 후 삼킨다(log-and-swallow). Micrometer 쪽 실패가 락 획득·보유·실패 보고를 막는 일은 없어야
하기 때문이다.
---
## outbox — 트랜잭셔널 아웃박스 릴레이 와이어링
### OutboxConfig
- **릴레이 use case 를 `@Service`가 아니라 `app-bootstrap`에서 수동 조립한다.**
`PublishPendingOutboxEventsUseCase`는 `batchSize`와 `inFlightTimeout` 같은 설정값을 생성자로 받아야
하는데, `application-core`는 설정(`OutboxSettings`)을 직접 읽으면 안 된다. 그래서 설정을 볼 수 있는
합성 루트(`OutboxConfig`)가 값을 꺼내 use case 를 손으로 만들어 넘긴다. use case 클래스의
`@UseCaseCapability` 애너테이션은 와이어링 방식과 무관하게 유지된다(ArchUnit 이 강제).
- **릴레이 use case 를 독립 컨텍스트 빈으로 등록하지 않는다.** 만약 빈으로 올리면 `adapter-web`의
`MethodSecurityConfig` 메서드 보안 pointcut(`@RequiresPermission`)이 이 타입을 CGLIB 프록시로
감싼다. 그런데 use case 가 `final` 클래스라 프록시 생성 자체가 실패하고, 설령 된다 해도 스케줄러
스레드에는 `Authentication`이 없어 매 릴레이 틱이 fail-closed 로 거부된다. 그래서 빈으로 올리지
않고, `outbox:relay` 권한 확인은 스케줄러 컨텍스트에서 관례로 둔다.
- **`outboxRelayScheduler` 빈은 `ca-skeleton.outbox.relay-enabled`로 게이팅한다(기본 true,
`matchIfMissing=true`).** 릴레이를 끄고 싶을 때 빈 자체가 만들어지지 않게 하기 위함. 키가 없으면
켜진 것으로 본다.
- **`outboxLeaderElection` 토큰 빈은 조건 없이(unconditional) 항상 등록한다.** `StartupSafetyValidator`
가 `APP_MULTI_INSTANCE_ENABLED=true`일 때 이 빈을 반드시 찾을 수 있어야 하기 때문이다. SKIP
LOCKED 는 멀티 인스턴스 여부와 무관하게 언제나 릴레이의 리더십 메커니즘이라, `@ConditionalOnProperty`
로 끌 수 있게 만들면 단순 조정 기능이 아니라 릴레이 정확성 자체가 깨진다.
- **`outboxMetrics` 빈은 `MeterRegistry`가 없을 때 no-op 이다.** Actuator 가 클래스패스에 없는
환경에서도 와이어링이 깨지지 않도록 `ObjectProvider`로 레지스트리를 선택적으로 주입한다.
### OutboxLeaderElectionToken
- **로직이 전혀 없는 마커(documentation artifact) 빈이다.** 이 타입은 동작을 갖지 않고, Spring
컨텍스트에 존재한다는 사실만으로 멀티 인스턴스 조정 빈 요구사항을 충족시킨다.
- **리더 선출을 외부 코디네이터 없이 PostgreSQL `FOR UPDATE SKIP LOCKED`로 구현한다는 것을
표현한다.** claim 쿼리에 SKIP LOCKED 를 걸면 각 릴레이 인스턴스가 서로 겹치지 않는(disjoint) 행
집합을 가져가므로, 별도 코디네이터 없이도 "각 인스턴스가 자기 파티션의 리더" 형태로 리더 선출
의미가 성립한다.
### OutboxMetrics
- **`MeterRegistry`가 없으면 모든 메트릭 연산이 no-op 이다.** Actuator 가 없는 환경에서도 릴레이가
정상 동작해야 하므로, 생성자에서 `ObjectProvider`로 레지스트리를 조회해 없으면 게이지도 만들지
않고 기록도 건너뛴다.
- **카운터는 PUBLISHED/FAILED/DEAD 만 집계하고 IN_FLIGHT 는 제외한다.** IN_FLIGHT 는 릴레이 사이클의
종료 결과(terminal outcome)가 아니라 처리 중을 나타내는 일시적 상태라 카운터 outcome 으로 의미가
없다.
- **MultiGauge 갱신 시 `overwrite=true`로 등록한다.** 매 스케줄러 틱마다 store 를 다시 조회해
게이지를 갱신하는데, 이전 틱에 있었지만 지금은 사라진 event type 같은 오래된 time-series 태그를
덮어써 제거하기 위함이다.
### OutboxRelayScheduler
- **합성 루트가 직접 등록하고 컴포넌트 스캔하지 않는다.** 이 스케줄러가 구동하는 릴레이 use case 가
(위 OutboxConfig 사유로) 일부러 빈이 아니기 때문에, 그 use case 를 조립하는 `OutboxConfig`가
스케줄러 등록까지 같이 소유한다.
- **릴레이 사이클에서 발생하는 예상치 못한 예외를 잡아 ERROR 로 로깅만 하고 삼킨다.** 스케줄러
스레드가 죽으면 릴레이가 조용히 멈추므로, 다음 틱을 위해 스레드를 살려둔다. 단, 개별 발행 실패
(FAILED/DEAD 전이)는 릴레이 use case 내부에서 이미 상태 전이와 ERROR 로그로 처리되어 결과에
반영되므로 이 catch 블록까지 오지 않는다 — 여기서 삼키는 것은 어디까지나 "예상치 못한" 예외다.
- **`@EnableScheduling`을 직접 켜지 않고 fixed-delay 를 쓴다.** 스케줄링은 이미 `IdempotencyConfig`를
통해 활성화돼 있어 중복으로 켤 필요가 없고, fixed-delay 는 릴레이 실행 시간과 무관하게 사이클이
겹치지 않도록(non-overlapping) 보장한다.
### OutboxSettings
- **여섯 개 설정값 모두 코드 리터럴 기본값을 쓰고 env placeholder 를 두지 않는다.** 신규 env key 를
추가하지 않는다는 결정에 따라, 값이 없으면 컴팩트 생성자에서 직접 기본값(예: `PT5S`, `20`,
`PT5M`, `PT10M`, `P7D`)을 채운다.
- **`reaper-interval`과 `published-retention`은 `adapter-persistence`의 `OutboxReaper`도 property
문자열로 읽는다.** 두 모듈이 같은 키를 공유하지만, 이 properties record 가 여섯 값 전체를 문서화
하는 단일 지점(single place) 역할을 한다.
---
## logging — 로그 시크릿 마스킹·샘플링·가명화
### LogMaskingPatterns
- **마스킹 정규식 규칙을 한 곳에만 둔 단일 진실 원천(SSOT)이다.** JSON 인코더 경로
(`SecretMaskingJsonGeneratorDecorator`, staging/prod/default 프로파일)와 사람이 읽기 쉬운 패턴 경로
(`SecretMaskingMessageConverter`, local/dev)가 같은 규칙을 공유한다. 규칙을 한 군데로 모아둔 이유는,
프로파일이나 로그 포맷을 바꿔도 가려지는 시크릿의 범위가 절대 달라지지 않게 하기 위해서다. 즉
"가독성을 위해 포맷을 바꿨더니 시크릿이 다시 노출되는" 사고를 구조적으로 막는다.
- **시크릿 값만 가리고 키/스킴은 남긴다.** `token=abc123` → `token=****`,
`Authorization: Bearer eyJ...` → `Authorization: Bearer ****`처럼 동작한다. 키와 인증 스킴(`Bearer`
등)을 남기는 이유는 진단할 때 "어떤 종류의 자격증명이 있었는지"는 알아야 하기 때문이다.
- **정규식 마스킹은 보증이 아니라 심층 방어(defence-in-depth)의 보조 수단이다.** 1차 방어선은
"로거가 애초에 본문/페이로드를 받지 않도록 설계한 것"(`FailOpenDependencyLogger`)이고, 이 정규식은
그걸 빠져나간 누출을 잡는 그물이다. 키 접두어 없는 Base64URL 블롭 같은 난독화된 형태는 놓칠 수
있으므로, 새로운 누출 형태가 보이면 이 규칙 목록을 운영 중에 계속 보강해야 한다.
### MetricsAsyncAppender
- **백프레셔로 로그가 버려질 때 그 사실을 메트릭으로 노출한다.** Logback `AsyncAppender`는 큐 여유가
`discardingThreshold` 아래로 떨어지면 `INFO`/`DEBUG` 같은 낮은 심각도 이벤트를 조용히 버린다
(`WARN`/`ERROR`는 항상 보존). 이렇게 조용히 사라지는 드롭은 관측이 안 되면 장애 분석 때 "로그가
비어 있는데 왜 비었는지 모르는" 상황을 만들기 때문에, 버릴 때마다 `log.appender.dropped.total`
Micrometer 카운터를 올리고(`appender`·`level` 태그) 그다음 상위 클래스에 실제 드롭을 위임한다.
- **카운터를 `Metrics.globalRegistry`로 발행한다.** Logback 은 Spring 컨텍스트보다 먼저 초기화되는데,
Spring Boot 가 시작 시 애플리케이션 `MeterRegistry`를 이 전역 컴포지트에 합류시킨다. 그래서 초기화
순서가 어긋나도 결국 평소 메트릭 엔드포인트에서 이 수치가 보인다.
- **`level` 태그를 `INFO`/`DEBUG`로만 한정한다.** `metrics.yaml`의 `allowed_values` 계약 때문이다.
루트 레벨을 TRACE 로 낮춰야만 가능한 `TRACE` 드롭은 상위 클래스가 여전히 버리지만 카운트하지는
않아서, 태그 카디널리티가 레지스트리 계약을 벗어나지 않게 유지한다.
### PseudonymizationConfig
- **`user_principal`(로그에 남는 사용자 식별자)를 전체 HMAC 방식으로 가명화한다.**
`UserPrincipalPseudonymizerPort`(application-core)를 HMAC-SHA-256 구현(adapter-identifier)에
바인딩하고, 솔트는 `PrivacySettings`에서 가져온다. 원본 식별자를 그대로 로그에 남기지 않으려는
개인정보 보호 결정이다. 실제 사용처는 `adapter-web`의 `RequestLoggingFilter`로, 보안 principal 이
MDC/로그에 닿기 전에 이 포트로 가린다.
- **`@ConditionalOnMissingBean`으로 기본 구현을 둔다.** 이 템플릿을 포크한 프로젝트가 자기만의
가명화기(예: 향후 솔트 회전을 지원하는 구현)를 등록하면 이 기본 빈이 비켜주도록, 즉 기본값은
제공하되 교체를 막지 않도록 하기 위해서다.
### SamplingTurboFilter
- **레벨을 의식하는 로그 샘플러다.** `WARN`/`ERROR`는 절대 샘플링하지 않고 항상 통과시킨다
(`FilterReply.NEUTRAL`). 진단·장애 신호인 경고/오류는 100% 보장해야 하고, 샘플링 대상은 `INFO`
이하만이라는 정책 때문이다. `INFO` 이하는 확률 `rate`로 보존하고 나머지는 `FilterReply.DENY`로
버린다.
- **단일 `rate` 노브로 제어한다.** `APP_LOG_SAMPLING_RATE`(logback `springProperty`)에서 주입되고,
운영자가 프로파일별로 설정한다(prod `0.1` = INFO 10% 샘플링, staging/dev/local `1.0` = 전부 보존).
고트래픽 vs 일반 엔드포인트의 더 세밀한 분기는 이 전역 rate 위에 마커 기반 확장으로 문서화만 되어
있고 여기서는 구현하지 않았다.
- **검증은 "경고 후 기본값(warn-and-default)" 방식이다.** `rate`가 `[0.0, 1.0]` 범위를 벗어나면
Logback 상태 시스템에 경고를 남기고 `1.0`(전부 보존)으로 폴백한다. 잘못된 설정이 로그를 소리 없이
버리는 쪽으로 가지 않고, 항상 안전한 "샘플링 안 함"으로 degrade 되게 한 선택이다.
### SecretMaskingJsonGeneratorDecorator
- **시크릿 마스킹의 JSON 인코더 쪽 팔이다.** `LogMaskingPatterns` 목록을 미리 적재한
`MaskingJsonGeneratorDecorator`로, staging/prod/default 프로파일의 `LogstashEncoder`에 연결된다.
JSON 생성 시점에 마스킹하기 때문에 어떤 구조화 필드가 시크릿을 담았든 상관없이 message·MDC
값·스택 트레이스 텍스트 등 모든 문자열 값을 덮는다. 이 "어디서 새든 잡는 그물(catch-net)" 성질이
Redaction Layer 1에 요구되는 핵심이다.
### SecretMaskingMessageConverter
- **시크릿 마스킹의 사람이 읽는 `PatternLayout` 쪽 팔이다.** local/dev 콘솔 패턴의 `%maskedMsg`
변환 워드로 등록되어, 가독성 위주의 인코더도 운영 JSON 경로와 똑같은 마스킹을 적용한다. 사람이
읽기 좋은 포맷으로 바꿨다는 이유로 시크릿이 다시 노출되지 않게 하려는 것이다. 여기서는 개발자
콘솔의 현실적 누출 벡터인 message 본문만 가린다(JSON 경로는 추가로 MDC·스택 트레이스 값까지
마스킹).
---
## metrics — 메트릭 계약 MeterFilter 설치
### MetricsCardinalityMeterFilter
- **금지 태그가 붙은 미터를 런타임에서 막는 방어 필터다.** 미터의 `Meter.Id`에 붙은 태그 키 중
하나라도 `ForbiddenMetricTags.FORBIDDEN`에 들어 있으면 그 미터를 `DENY`하고, 아니면 `NEUTRAL`을
반환한다. `user_id`, `request_id`, `raw_url`, `raw_query`, `raw_header_value`, `ip_address` 같은
고카디널리티(high-cardinality) 라벨 키는 고유 값마다 Prometheus 시계열을 하나씩 만들어 수백만
개로 폭증할 수 있어 차단한다.
- **이 필터는 카디널리티 계약의 런타임(runtime) 절반이다.** 정적(static) 절반은 레지스트리 계약
테스트(`MetricsAlertingContractTest`)가 담당한다 — 둘이 짝을 이뤄 심층 방어를 구성한다.
- **상태가 없어(stateless) 여러 레지스트리에 공유해도 안전하다.** public no-arg 생성자는
`MetricsContractConfig.install()`이 직접 인스턴스화하기 위한 계약이다.
### MetricsContractConfig
- **`@PostConstruct`에서 `MeterFilter`들을 `MeterRegistry`에 직접 설치한다.** `@Bean MeterFilter`로
등록하지 않는 이유: 이 템플릿 클래스패스에는 `@Bean MeterFilter`를 자동 수집하는 Spring Boot
Actuator 자동설정(`MeterRegistryCustomizer`)이 없어서 `@Bean MeterFilter`는 그냥 동작하지 않는
(inert) 죽은 빈이 되기 때문이다. `OutboundHttpResilienceConfig`에서 확립한 선례를 따른다.
- **설치 순서가 중요하다.** (1) `MetricsCardinalityMeterFilter`(deny-list)를 먼저 설치해 금지 태그가
붙은 미터가 분포 필터에 닿기 전에 거부되도록 하고, (2) 그다음 `MetricsDistributionMeterFilter`
(SLO 기반 히스토그램 설정)를 설치한다.
- **필터는 설치 이후 등록되는 미터에만 적용된다.** `@PostConstruct`는 Spring 빈 생명주기 중 애플리
케이션 코드가 어떤 미터든 등록하기 전에 실행되므로 모든 미터가 필터 적용 대상이 된다.
- **`MeterRegistry` 부재 시 no-op.** `ObjectProvider.getIfAvailable()`로 해석해 레지스트리 빈이
없으면(Actuator 미탑재) DEBUG 로그만 남기고 설치를 건너뛴다.
- **`install(MeterRegistry)`를 public static 으로 둔 이유.** 계약 테스트가 Spring 컨텍스트 없이
`SimpleMeterRegistry`에 대해 직접 구동할 수 있게 하기 위함이다.
### MetricsDistributionMeterFilter
- **소유한(owned) 타이머 메트릭 5종에만 SLO 기반 히스토그램·백분위 설정을 적용한다.** 대상:
`http.server.requests`, `http.server.requests.latency`, `dependency.client.requests`,
`db.query.duration`, `jvm.gc.pause`. 이들은 `metrics.yaml`에서 이 브랜치가 소유하고
`histogram_buckets: slo_driven`인 행이다. `resilience4j.circuitbreaker.calls`,
`hikaricp.connections.acquire`처럼 소비만 하고 소유하지 않는(consumed-but-not-owned) 행은 다른
브랜치 소유라 의도적으로 제외 — 덮어쓰면 안 된다.
- **히스토그램 전략은 두 축이다.**
- *인스턴스 간 집계 가능한 진실의 원천*: `percentilesHistogram(true)`(= `publishPercentileHistogram`)가
`_bucket` 시계열을 생성하고, Prometheus 에서 `histogram_quantile()`로 인스턴스 간 집계할 수 있다.
이것이 다중 인스턴스 배포의 정식 p50/p95/p99 다.
- *클라이언트 측 편의값(집계 불가)*: `percentiles(0.5, 0.9, 0.95, 0.99)`(= `publishPercentiles`)는
단일 인스턴스 가시성을 위한 사전 계산 분위 게이지를 제공한다. 이 값들은 인스턴스 간 평균을 내면
안 된다(통계적으로 틀림). 인스턴스 간 집계는 반드시 `histogram_quantile()`로 한다.
- **SLO 경계값 `100ms / 500ms / 1s / 5s`는 잠정 SLO 역산이며 외부 표준에서 유도한 값이 아니다.**
정식 SLO 가 채택되면 재검토 대상이다. 함께 설정하는 `minimumExpectedValue=1ms`,
`maximumExpectedValue=10s`는 히스토그램 버킷의 관측 범위를 한정한다.
- **상태가 없어 여러 레지스트리에 공유해도 안전하고,** public no-arg 생성자는
`MetricsContractConfig.install()`이 직접 인스턴스화하기 위한 계약이다.
---
## management/security — 액추에이터 엔드포인트 보안 체인
### ManagementSecurityConfig
- **`actuatorSecurityFilterChain`은 `@Order(0)`으로 메인 앱 체인(adapter-web `SecurityConfig`)보다
앞서 실행된다.** 그래서 액추에이터 엔드포인트에 매칭되는 요청은 앱 체인이 아니라 이 체인이
처리한다. 보안 매처는 `EndpointRequest.toAnyEndpoint()`로, 관리 포트의 `/actuator/**` 경로만 이
체인 범위에 든다.
- **접근 정책.** `health`, `info`, `prometheus`는 permit-all 이다. 자격 증명 없이 Kubernetes 프로브와
Prometheus 스크레이프가 접근할 수 있어야 하고, 이 엔드포인트들은 민감 데이터를 노출하지 않기
때문이다(health 상세는 when-authorized). 나머지 액추에이터 엔드포인트는 모두 인증이 필요하다.
- **`loggers` 쓰기 차단.** 런타임 로그 레벨 변경은 `POST /actuator/loggers/{name}`이고, 레벨 리셋은
`DELETE /actuator/loggers/{name}`이다. 둘 다 변형(mutation) 작업이므로 `denyAll()`로 모두에게
(인증된 사용자 포함) 거부한다 — 인증만으로 쓰기 권한을 주지 않는다. `loggers` 읽기는 아래
`authenticated()`로 흘러 인증을 요구한다.
- **인증 실패 시 기본값 403 대신 401(자격 증명 필요)을 반환한다(`HttpStatusEntryPoint(UNAUTHORIZED)`).**
대화형 로그인이나 basic-auth realm 은 제공하지 않는다 — 관리 포트는 네트워크 ACL 뒤에 있으므로
올바른 HTTP 의미(credentials required)만 신호하면 된다.
- **운영에서 deny-by-default 보장은 관리 포트를 별도 포트로 분리해 네트워크 ACL 수준에서 강제한다.**
이 in-process 체인은 그 위의 심층 방어 계층이다.
- **SHAPE-OWNERSHIP(하드 규칙): 이 클래스는 `HealthEndpoint` / `HealthIndicator` / `HealthComponent`를
import·구현·의존해선 안 된다** — health 엔드포인트의 형태(shape)는 runtime-health 브랜치 소유다.
보안 매칭은 오직 엔드포인트 id 문자열로만 하고 health 내부 구조에는 절대 손대지 않는다. ArchUnit
규칙 `management_security_does_not_depend_on_health_internals`(`CleanArchitectureTest`)가 이를 정적
으로 강제한다.
---
## tracing — 분산 트레이싱 wiring과 샘플링 정책
### TracingConfig
- **컴포지션 루트에서 트레이싱을 조립한다.** 두 가지를 한다: (1) 시작 시 `tracing.sampling.rate`
게이지 등록, (2) `SpanErrorRecorder` 빈 등록.
- **`SpanErrorRecorder` 빈은 `@ConditionalOnBean(Tracer.class)`가 아니라 `ObjectProvider<Tracer>`로
런타임 존재 여부를 직접 조회한다.** 이유: `@ConditionalOnBean`은 사용자 정의 `@Configuration`에서
autoconfiguration 이 만드는 빈(여기서는 `Tracer`)을 조건으로 쓸 때 빈 등록 순서가 보장되지 않아
신뢰할 수 없기 때문이다. `Tracer`가 있으면 `MicrometerSpanErrorRecorder`를, 없으면
`SpanErrorRecorder.NOOP`을 반환한다.
- **`@ConditionalOnMissingBean(SpanErrorRecorder.class)`를 붙여, 테스트 mock 이나 fork 가 자체
`SpanErrorRecorder` 빈을 이미 등록한 경우 이 빈은 backoff 한다.** adapter-web 의
`GlobalExceptionHandler`는 `ObjectProvider`로 `SpanErrorRecorder`를 조회하므로, 자기 자신의 NOOP
self-default 에서 여기서 등록한 실제 구현으로 자동 교체된다.
- **OTel/Micrometer tracer 런타임은 이 repo 에서 seam 이 활성 상태다.** `micrometer-tracing-bridge-otel`
+ `opentelemetry-exporter-otlp`가 `app-bootstrap/build.gradle`에 실제로 wiring 되어 있다. 단, span
exporter 는 `OTEL_EXPORTER_OTLP_ENDPOINT`가 비어 있는 동안 꺼져 있고, 엔드포인트를 설정하면
export 가 시작된다.
- **게이지 등록 시 active profile 은 `Environment.getActiveProfiles()`의 첫 번째 값을 쓴다.** 멀티
프로파일 배포에서는 맨 앞에 나열된 프로파일이 권위 있는 배포 환경이라는 규약이다. `MeterRegistry`가
classpath 에 없으면(Actuator 미탑재) 게이지 등록은 조용히 no-op 이 된다.
- **`TracingSamplingRateGaugeRegistrar` record 는 등록된 active profile 과 resolved rate 를 노출하는
값 홀더다.** 정식 포트나 use case 가 아니라, 살아있는 `MeterRegistry` 없이도 테스트가 "무엇이
등록되었는지" 검사할 수 있게 하는 bootstrap 내부 전용 장치다.
### TracingSettings
- **`ca-skeleton.tracing.*` 바인딩 `@ConfigurationProperties` record.** 세 env 키가 들어온다:
`APP_TRACING_ENABLED` → `enabled`, `APP_TRACING_SAMPLE_RATE` → `sampleRate`,
`OTEL_EXPORTER_OTLP_ENDPOINT` → `exporter.otlpEndpoint`.
- **`sampleRate`가 blank 이면 숫자 검증을 건너뛴다.** blank 는 "값이 잘못됐다"가 아니라 "프로파일별
기본값에 위임하라"는 신호이기 때문이다. 실제 프로파일별 기본값(prod=0.01, staging=0.10,
dev/local=1.0)은 `TracingSampleRateResolver`가 소유하고, resolved 된 값은
`TracingSamplingEnvironmentPostProcessor`가 Spring Boot 네이티브
`management.tracing.sampling.probability`로 브리지한다.
- **검증은 시작 시 fail-fast 다.** `sampleRate`가 non-blank 인데 [0.0, 1.0] float 이 아니면 시작이
실패하고, `otlpEndpoint`가 non-empty 인데 scheme 없는 잘못된 URL 이면 시작이 실패한다. `enabled`는
Spring 바인딩이 `boolean` 타입을 강제하므로 추가 검증이 필요 없다. `otlpEndpoint`가 비어 있으면
exporter off 상태(seam 기본값)이고, 이것이 정상 동작이므로 검증을 통과시킨다.
- **`sampleRateValue()`는 호출 전 `sampleRate().isBlank()` 확인이 caller 규약이다.** blank 인 상태로
호출하면 `IllegalStateException`을 던지는데, 이는 파싱 오류가 아니라 caller 규약 위반을 드러내기
위한 것이다.
### TracingSampleRateResolver
- **프로파일별 트레이싱 샘플 비율 SSOT 다.** prod=0.01(1%), staging=0.10(10%), dev/local=1.0(100%),
그 외=1.0. `APP_TRACING_SAMPLE_RATE`가 non-blank 이고 [0,1] float 로 파싱되면 프로파일 기본값을
override 한다.
- **Spring 의존성이 전혀 없는 순수 Java 로 작성했다.** 어떤 컨텍스트도 없이 단위 테스트할 수 있고,
`EnvironmentPostProcessor`(컨텍스트 생성 이전 단계)와 `TracingConfig`(빈 생성 단계) 양쪽에서 동일
로직을 재사용하기 위함이다.
### TracingSamplingRateGauge
- **`tracing.sampling.rate` 게이지를 등록한다.** metric 계약: type=gauge, tag=`profile`(prod/staging/
dev/local 허용), value=resolved effective sample rate(double in [0.0, 1.0]).
- **오직 active Spring profile 의 시리즈 하나만 방출하므로 cardinality 는 1이다.** tag 는 프로파일
이름을 정규화하지 않고 그대로 쓴다. 따라서 비표준 프로파일(예: "qa")로 띄워도 시리즈 하나는 정상
방출되고, 단지 4-value allowlist 를 벗어날 뿐이다 — 이는 런타임 에러가 아니라 레지스트리 문서상의
관심사다.
- **`MeterRegistry`를 `ObjectProvider`로 조회하며, Actuator 미탑재로 빈이 없으면 등록을 조용히
건너뛴다.** 이는 `BackgroundJobMetrics` / `OutboxMetrics`의 선례를 그대로 따른다.
### TracingSamplingEnvironmentPostProcessor
- **resolved 된 유효 샘플 비율을 Spring Boot 네이티브 키 `management.tracing.sampling.probability`로
단일화(브리지)하는 `EnvironmentPostProcessor`다.** 이를 통해 "프로파일별 기본값 + env override"라는
우리 규약을 Spring Boot 의 기본 샘플링 메커니즘에 그대로 연결한다.
- **브리지 우선순위.** (1) 사용자가 `management.tracing.sampling.probability`를 이미 명시했으면
덮어쓰지 않고 그대로 둔다, (2) `ca-skeleton.tracing.sample-rate`가 non-blank 면 그 값 사용, (3)
blank 면 `TracingSampleRateResolver.defaultRateForProfile(...)`로 active profile 기본값 적용.
- **`META-INF/spring.factories`에 `org.springframework.boot.EnvironmentPostProcessor` 키로 등록된다.**
`EnvironmentPostProcessor`는 ApplicationContext 가 생성되기 전에 실행되므로 일반 빈이 아니라
Spring Boot bootstrap factory 로 등록해야 한다.
### MicrometerSpanErrorRecorder
- **NOOP 을 대체하는 Micrometer/OTel 기반 실제 `SpanErrorRecorder`다.** 현재 span 에 예외와
`error.code` 태그를 기록한다. 계약 이행: `Span.error(Throwable)` 호출(OTel bridge 를 통해
`recordException` + span status ERROR 매핑), `span.tag("error.code", errorCode)`(ca-tmpl 레지스트리
속성명), null `error`는 방어적 no-op, 현재 span 이 없으면 no-op.
- **"sampled span 에만 스택트레이스 부착" 정책은 의도적으로 구현하지 않았다.** Micrometer Tracing
추상화 레벨에서는 sampled 여부를 직접 제어할 수 없기 때문이다(`Span.isNoop()`은 OTel NOOP span
여부일 뿐 sampling 여부가 아니다). 다만 OTel SDK 의 unsampled span 은 이미 no-op 으로 처리되므로
명시적 sampled-only 분기가 없어도 실제 성능 부담은 없다.
---
## settings — @ConfigurationProperties 검증 정책
이 모듈의 설정 record 들은 대체로 두 가지 검증 전략 중 하나를 고른다: **fail-fast**(잘못되면 기동
중단)와 **warn-and-default**(경고만 남기고 안전한 기본값으로 진행). 어느 쪽을 쓰는지가 각 record 의
핵심 결정이다.
### BootstrapSettings
- **`appName`은 비어 있으면 시작을 실패시킨다(fail-fast).** `ca-skeleton.bootstrap.*` 바인딩이며,
앱 이름은 운영자가 직접 제공해야 하고 합리적인 기본값이 존재하지 않으므로, 비어 있거나 blank 면
`@NotBlank`로 막는다(Spring Boot 가 `BindValidationException`을 띄우고 컨텍스트 기동을 거부).
다른 settings 의 "warn-and-default"와 달리 여기서 fail-fast 를 택한 이유는, 잘못된 앱 이름으로
조용히 기동되는 것보다 즉시 멈추는 편이 안전하기 때문이다.
### LoggingSettings
- **모든 항목이 "warn-and-default" 정책이다.** `ca-skeleton.logging.*` 바인딩(원천은 .env). 잘못된
값은 경고 로그를 남기고 안전한 기본값으로 진행할 뿐 시작을 실패시키지 않는다.
- **fail-fast 대신 warn-and-default 를 택한 이유.** Logback 은 이 record 가 바인딩되기 전에 자기
초기화 단계에서 `<springProperty>` 바인딩으로 이미 같은 값들을 자체 기본값과 함께 소비했다. 따라서
이 record 의 역할은 로깅을 다시 강제하는 것이 아니라, 입력이 잘못됐을 때 운영자에게 명확한 경고를
표면화하는 것이다.
- **size 문자열(`maxSize`, `totalSizeCap`)은 검증하지 않는다.** 이 값들의 파싱 계약은 logback 이
소유하므로 logback 에게 맡긴다.
### PrivacySettings
- **보안/감사 로그에서 `user_principal`을 가명화하는 데 쓰는 HMAC salt 를 보관한다.**
`ca-skeleton.privacy.*` 바인딩(원천 `APP_PRIVACY_PSEUDONYMIZATION_SALT`). 알고리즘은 HMAC-SHA-256
+ 90일 회전 salt 이고, 이 salt 는 `secret`-tier 값이라 prod 에서는 반드시 secret manager 에서
공급되어야 한다.
- **검증은 `LoggingSettings`와 일관되게 "warn-and-default"다.** blank salt 는 경고를 남기고, 명확히
표시된 dev sentinel(`__LOCAL_DEV_` 접두사)로 폴백해 local/test 실행이 절대 기동에 실패하지 않게
한다.
- **이 sentinel 을 prod 로 승격하는 것은 별도 계약이 독립적으로
차단한다** — prod 프로파일에서 `__LOCAL_DEV_` 값이면 시작이 실패한다. 그 게이트는 이 record 의
책임 밖이라 여기서 중복 강제하지 않는다.
### RuntimeSafetySettings
- **시작 시 `StartupSafetyValidator`가 강제하는 운영 안전 토글이다.** `ca-skeleton.runtime.*`
바인딩(원천 `APP_ERROR_DETAIL_EXPOSURE_ENABLED`, `APP_LOG_BODY_CAPTURE_ENABLED`,
`APP_MULTI_INSTANCE_ENABLED`, 모두 기본값 `false`).
- **`errorDetailExposureEnabled` / `logBodyCaptureEnabled`는 prod-unsafe 토글이다.** `prod`
프로파일에서 둘 중 하나라도 켜면 시작이 실패한다. 그래야 내부 에러 상세나 요청 본문 캡처가
프로덕션에서 조용히 켜진 채 남는 일이 없다.
- **`multiInstanceEnabled`가 `true`면 인스턴스 조율 capability 빈들(lock / cache-stampede / leader /
rate-limit / migration)이 모두 존재하는지 단언하고, 하나라도 없으면 시작을 실패시킨다.** 멀티
인스턴스 모드를 켜놓고 조율 인프라가 빠진 채 기동되는 위험한 상태를 막기 위함이다.
---
## build.gradle — 의존성 구성 근거
app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결정이 많다. 빌드 파일에는 한 줄
요약만 두고, 비자명한 근거는 여기에 모은다. 바탕 원칙은 **api vs implementation 정책**(CLAUDE.md):
모듈 간 의존은 기본 `implementation`이라 transitive 로 새지 않는다 — 그래서 테스트에서 그 타입이
필요하면 여기서 **명시적으로** 다시 선언한다.
### 런타임(production) 의존성
- **`flyway-core`를 직접 의존하는 이유.** PostgreSQL vendor 모듈이 `flyway-database-postgresql`와
마이그레이션 스크립트를 소유하지만, 합성 루트는 Flyway API 자체가 필요하다. `MigrationStartupConfig`의
`migrationStartupRunner`(FlywayMigrationStrategy)가 `migrate()`를 직접 구동하고 `FlywayException`을
exit-70 `MigrationFailedException`으로 번역하기 때문이다.
- **`spring-boot-starter-security`를 compile classpath 에 두는 이유.** adapter-web 이 security 를
`implementation`(not `api`)으로 선언해서 Spring Security 타입이 app-bootstrap 컴파일 경로로 새지
않는다. 그런데 `ManagementSecurityConfig`가 `HttpSecurity`/`SecurityFilterChain`/`EndpointRequest`를
쓴다. cross-cutting 보안 와이어링은 합성 루트가 소유한다는 원칙(AGENTS.md)에 따라 여기서 직접
의존한다.
- **`micrometer-core`** — `OutboxMetrics` 카운터/게이지용. `ObjectProvider<MeterRegistry>`라 registry
가 없으면 no-op 이다(registry 는 Actuator 가 제공).
- **tracing(`micrometer-tracing-bridge-otel` + `opentelemetry-exporter-otlp`)** — OTel/Micrometer
tracer 런타임. exporter 는 `OTEL_EXPORTER_OTLP_ENDPOINT`가 비면 켜진 채로 대기만 하고 export 는
안 한다. 버전은 Spring Boot BOM 이 관리.
- **`logstash-logback-encoder`를 `runtimeOnly`가 아니라 `implementation`으로 올린 이유.** 보통 JSON
로그 인코더는 런타임에만 있으면 되지만, `StartupFailures`가 `StructuredArguments`를 **compile
time**에 호출해 `startup.phase`/`error.code`/`error.category` JSON 필드를 방출하므로 compile
classpath 에 있어야 한다.
### 테스트 의존성 (transitive 로 안 새서 명시 선언)
- **`testImplementation project(':adapter-persistence-postgresql')`** — vendor 클래스
(`PostgreSqlSqlStateErrorMapping`/`PostgreSqlOutboxClaimRepository`)를 test compile 경로에 올려,
full-matrix contract 테스트가 실제 production composition 을 배선하게 한다(런타임 의존은 `runtimeOnly`).
- **`testRuntimeOnly postgresql`** — PG JDBC 드라이버. vendor 모듈이 `runtimeOnly`로 선언해
app-bootstrap 테스트 경로로 안 새므로 여기서 명시한다.
- **`spring-boot-starter-data-jpa` + `HikariCP`** — adapter-persistence-rdbms 가 `implementation`이라
JPA/Hikari 가 compile 경로로 오지 않는다. outbox contract 테스트가 minimal Spring context 를 직접
만들어 이들이 컴파일 시점에 필요하다.
- **`spring-integration-jdbc`** — `JdbcLockRegistry`/`DefaultLockRepository`도 rdbms 의
`implementation`이라 안 샌다. `DistributedLockProviderContractTest`가 같은 Testcontainers PG
DataSource 에 대해 독립 registry 두 개(= 두 인스턴스 시뮬레이션)를 직접 만들어 상호배제·리스 만료를
검증한다. 테스트 전용 — production 와이어링은 전부 `DistributedLockPersistenceConfig`(rdbms) 소유.
- **`spring-boot-starter-json`** — `JacksonAutoConfiguration`은 jackson-databind 가 test 경로에 있어야
활성화되고, deserialization-policy 경계 테스트가 `DeserializationFeature` enum 을 직접 읽는다.
- **Testcontainers(`postgresql`/`junit-jupiter`)** — outbox contract 테스트용. 버전은 Spring Boot
BOM 이 관리.
- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated
loggers / loggers 쓰기 거부)를 검증한다.
### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용)
ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지
증명한다. production classpath 엔 없어야 하므로 전부 `testCompileOnly`이고, 각 금지 glob 을 독립적으로
증명하려고 의존성을 쪼개 둔다.
- `spring-tx` — `..architecture.violations.*`의 일반 위반 fixture.
- `spring-webmvc` / `spring-websocket` / `jakarta.websocket-api` — streaming 위반 fixture +
over-block 가드(streaming-response-contract). spring-web(`org.springframework.http..`)는
spring-webmvc 를 통해 transitive 로 도착한다.
- `kafka-clients` / `jakarta.ws.rs-api` — transport-free 도메인 이벤트 fixture
(domain-modeling-guardrails)가 금지된 broker/wire/HTTP 패키지를 import 한다.
- `spring-cloud-context` — `@RefreshScope` 금지(no-refresh-scope) fixture 용. Spring Cloud 는
production 에 없고 규칙은 애너테이션을 FQN 문자열로 참조한다. BOM 이 spring-cloud 좌표를 관리하지
않아 버전을 명시 고정한다.
- **`testImplementation project(':sample-portfolio')`** — ArchUnit 이 템플릿 reference 구현을 분석하려고
test 경로에만 둔다. **production 은 절대 sample-portfolio 에 의존 금지** —
`production_code_does_not_depend_on_sample_portfolio` 규칙이 강제한다.
### 빌드 설정
- **test JVM UTC 고정(`-Duser.timezone=UTC`)** — `RuntimeHealthLifecycleContractTest`가
`TimeZone.getDefault().getID() == "UTC"`를 단언해, 호스트 로케일과 무관하게 타임스탬프 산술이
결정적이게 한다. production UTC 는 여기서 강제하지 않으며 container-runtime-contract(Dockerfile
`TZ=UTC`)가 소유한다.
- **`bootRun.workingDir = rootProject.projectDir`** — `src/.env`를 읽도록 Gradle 루트(src/)에서
실행한다.
+192
View File
@@ -0,0 +1,192 @@
// Application entry point. Wires every module together and runs Spring Boot.
apply plugin: 'org.springframework.boot'
// The sample fixture (sampleFixture -> sample-portfolio -> objectstorage) pulls software.amazon.awssdk:s3
// onto the sample-on test classpath; its version is managed by the AWS SDK v2 BOM (NOT the Spring Boot
// BOM). Import that BOM at this module's scope so the transitive s3 dependency resolves for the ArchUnit
// sample-on analysis. AWS-SDK version SSOT = ext.awsSdkVersion.
dependencyManagement {
imports {
mavenBom "software.amazon.awssdk:bom:${awsSdkVersion}"
}
}
// feature-sample-removal-adoption-contract D7 — ordinary tests may inspect the sample fixture,
// while sampleOffTest compiles the same core suite without the sample project on either classpath.
configurations {
sampleFixture {
canBeConsumed = false
canBeResolved = false
}
}
sourceSets {
sampleOffTest {
java.srcDirs = sourceSets.test.java.srcDirs
resources.srcDirs = sourceSets.test.resources.srcDirs
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
testCompileClasspath.extendsFrom sampleFixture
testRuntimeClasspath.extendsFrom sampleFixture
sampleOffTestImplementation.extendsFrom testImplementation
sampleOffTestCompileOnly.extendsFrom testCompileOnly
sampleOffTestRuntimeOnly.extendsFrom testRuntimeOnly
sampleOffTestAnnotationProcessor.extendsFrom testAnnotationProcessor
}
dependencies {
implementation project(':domain-core')
implementation project(':application-core')
implementation project(':adapter:outbound:persistence-jpa')
implementation project(':adapter:outbound:support')
implementation project(':adapter:outbound:messaging')
implementation project(':adapter:outbound:cache-redis')
implementation project(':adapter:outbound:notification')
implementation project(':adapter:outbound:httpclient')
implementation project(':adapter:outbound:identifier')
implementation project(':adapter:inbound:web')
implementation project(':shared-contract')
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
// Boot 4 Flyway API/autoconfiguration: the composition root drives startup migration
// (MigrationStartupConfig). See README.
implementation 'org.springframework.boot:spring-boot-flyway'
// Flyway API: MigrationStartupRunner directly invokes Flyway. Kept explicit for readability.
implementation 'org.flywaydb:flyway-core'
// Micrometer core for OutboxMetrics meters (no-op without a MeterRegistry). See README.
implementation 'io.micrometer:micrometer-core'
// OTel/Micrometer tracer runtime (exporter stays off until an OTLP endpoint is set). See README.
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
// Actuator + Prometheus registry (health/info/prometheus/loggers endpoints).
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
// Security types for ManagementSecurityConfig (not reachable via adapter-web's implementation dep). See README.
implementation 'org.springframework.boot:spring-boot-starter-security'
// test-only: ArchUnit needs actuator types to verify the health-shape guardrail. See README.
testImplementation 'org.springframework.boot:spring-boot-starter-actuator'
// test-only: @WithMockUser for the actuator security authorization tests. See README.
testImplementation 'org.springframework.security:spring-security-test'
// test-only: Testcontainers PostgreSQL for the outbox contract tests.
testImplementation 'org.testcontainers:testcontainers-postgresql'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
// test-only: Boot 4 split JPA slice annotations into dedicated test modules.
testImplementation 'org.springframework.boot:spring-boot-data-jpa-test'
// test-only: PG JDBC driver (the vendor module's runtimeOnly does not leak here). See README.
testRuntimeOnly 'org.postgresql:postgresql'
// test-only: JPA/Hikari for the outbox tests' minimal context (not leaked from rdbms). See README.
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa'
testImplementation 'com.zaxxer:HikariCP'
// test-only: JdbcLockRegistry for the distributed-lock contract test (two simulated instances). See README.
testImplementation 'org.springframework.integration:spring-integration-jdbc'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
// test-only: jackson-databind for the deserialization-policy boundary test. See README.
testImplementation 'org.springframework.boot:spring-boot-starter-json'
// sample-on only: ArchUnit analyses the reference impl. sampleOffTest intentionally omits it.
sampleFixture project(':sample-portfolio')
// test-only: ArchUnit violation fixtures intentionally import forbidden types. See README.
testCompileOnly 'org.springframework:spring-tx'
// test-only: streaming/websocket violation fixtures import these forbidden packages. See README.
testCompileOnly 'org.springframework:spring-webmvc' // SseEmitter, ResponseBodyEmitter, StreamingResponseBody
testCompileOnly 'org.springframework:spring-websocket' // org.springframework.web.socket..
testCompileOnly 'jakarta.websocket:jakarta.websocket-api' // jakarta.websocket..
// test-only: transport-free domain-event fixtures import these forbidden broker/wire packages. See README.
testCompileOnly 'org.apache.kafka:kafka-clients' // org.apache.kafka..
testCompileOnly 'jakarta.ws.rs:jakarta.ws.rs-api' // jakarta.ws.rs..
// test-only: @RefreshScope for the no-refresh-scope violation fixture (version pinned; not in the BOM). See README.
testCompileOnly 'org.springframework.cloud:spring-cloud-context:4.1.4' // org.springframework.cloud.context..
// JSON log encoder; implementation (not runtimeOnly) because StartupFailures uses StructuredArguments at compile time. See README.
implementation 'net.logstash.logback:logstash-logback-encoder:8.0'
// test-only: ApprovalTests JSON snapshot verification for the contract-verification suite
// (feature-contract-verification-test-suite D7 — envelope/error shape snapshots). See README.
testImplementation 'com.approvaltests:approvaltests:31.0.0'
// test-only: JUnit Platform Test Kit — proves optional-adapter tests report SKIPPED (never FAILED)
// when their enable-flag env var is unset (feature-contract-verification-test-suite D3, Claims #7). See README.
testImplementation 'org.junit.platform:junit-platform-testkit'
}
// Pin UTC for the TEST JVM so timestamp tests are host-locale-independent (production UTC owned elsewhere). See README.
tasks.named('test') {
jvmArgs '-Duser.timezone=UTC'
}
tasks.register('sampleOffTest', Test) {
group = 'verification'
description = 'Compiles and runs the core test suite without sample-portfolio on the classpath.'
testClassesDirs = sourceSets.sampleOffTest.output.classesDirs
classpath = sourceSets.sampleOffTest.runtimeClasspath
useJUnitPlatform {
excludeTags 'quarantine'
}
shouldRunAfter tasks.named('test')
outputs.upToDateWhen { false }
systemProperty 'ca.sample.mode', 'off'
jvmArgs '-Duser.timezone=UTC'
}
// The custom source set compiles the same test corpus, so it follows the repository-wide
// warning-only policy already applied to checkstyleTest and spotbugsTest in the root build.
tasks.named('checkstyleSampleOffTest') {
ignoreFailures = true
}
tasks.named('spotbugsSampleOffTest') {
ignoreFailures = true
}
// feature-developer-experience-contract D2/D7 delegation: the DX entrypoint verifies production
// isolation and the sample-on build contract. sample-off is the separate sampleOffTest task.
tasks.register('bootstrapSampleContract', Test) {
group = 'developer experience'
description = 'Runs the delegated sample production-isolation/build contract for bootstrap.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform()
filter {
includeTestsMatching 'dev.caskeleton.bootstrap.contract.SampleRemovalSmokeContractTest'
}
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
bootJar {
mainClass = 'dev.caskeleton.bootstrap.CaSkeletonApplication'
}
// Run from the repo's src/ root and inject src/.env into the Java process environment.
// Boot 4 initializes profiles/logging before spring-dotenv can reliably contribute .env values.
bootRun {
workingDir = rootProject.projectDir
doFirst {
File envFile = rootProject.file('.env')
if (!envFile.isFile()) {
return
}
envFile.eachLine { raw ->
String line = raw.trim()
if (line.isEmpty() || line.startsWith('#') || !line.contains('=')) {
return
}
int separator = line.indexOf('=')
String key = line.substring(0, separator).trim()
String value = line.substring(separator + 1).trim()
if (!key.isEmpty() && System.getenv(key) == null && !environment.containsKey(key)) {
environment key, value
}
}
}
}
+464
View File
@@ -0,0 +1,464 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-classic:1.5.34=sampleFixture
ch.qos.logback:logback-core:1.5.21=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.34=sampleFixture
com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.21.4=sampleFixture
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.21.4=sampleFixture
com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.21.4=sampleFixture
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4=sampleFixture
com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.21.4=sampleFixture
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4=sampleFixture
com.fasterxml.jackson.module:jackson-module-parameter-names:2.21.4=sampleFixture
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.21.4=sampleFixture
com.fasterxml:classmate:1.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.3=sampleFixture
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.f4b6a3:uuid-creator:6.1.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=sampleFixture,spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:9.37.4=sampleFixture
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.squareup.okhttp3:okhttp-jvm:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.squareup.okhttp3:okhttp:4.12.0=sampleFixture
com.squareup.okhttp3:okhttp:5.2.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.squareup.okio:okio-jvm:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.squareup.okio:okio-jvm:3.6.0=sampleFixture
com.squareup.okio:okio:3.16.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.squareup.okio:okio:3.6.0=sampleFixture
com.sun.istack:istack-commons-runtime:4.1.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-api:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine:1.3.0=sampleOffTestRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.zaxxer:HikariCP:6.3.3=sampleFixture
com.zaxxer:HikariCP:7.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.cdimascio:dotenv-java:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
io.github.resilience4j:resilience4j-bulkhead:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-core:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-micrometer:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-ratelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-retry:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-timelimiter:2.2.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.micrometer:context-propagation:1.1.4=sampleFixture
io.micrometer:context-propagation:1.2.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.15.12=sampleFixture
io.micrometer:micrometer-commons:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.15.12=sampleFixture
io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-jakarta9:1.15.12=sampleFixture
io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.15.12=sampleFixture
io.micrometer:micrometer-observation:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-registry-prometheus:1.15.12=sampleFixture
io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing-bridge-otel:1.5.12=sampleFixture
io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-tracing:1.5.12=sampleFixture
io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.7.Final=testRuntimeClasspath
io.netty:netty-codec-base:4.2.7.Final=testRuntimeClasspath
io.netty:netty-codec-compression:4.2.7.Final=testRuntimeClasspath
io.netty:netty-codec-http2:4.2.7.Final=testRuntimeClasspath
io.netty:netty-codec-http:4.2.7.Final=testRuntimeClasspath
io.netty:netty-codec-marshalling:4.2.7.Final=testRuntimeClasspath
io.netty:netty-codec-protobuf:4.2.7.Final=testRuntimeClasspath
io.netty:netty-codec:4.2.7.Final=testRuntimeClasspath
io.netty:netty-common:4.2.7.Final=testRuntimeClasspath
io.netty:netty-handler:4.2.7.Final=testRuntimeClasspath
io.netty:netty-resolver:4.2.7.Final=testRuntimeClasspath
io.netty:netty-transport-classes-epoll:4.2.7.Final=testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.7.Final=testRuntimeClasspath
io.netty:netty-transport:4.2.7.Final=testRuntimeClasspath
io.opentelemetry.semconv:opentelemetry-semconv:1.32.0=sampleFixture
io.opentelemetry.semconv:opentelemetry-semconv:1.37.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-api:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-context:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-exporter-common:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-exporter-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-exporter-otlp-common:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-exporter-otlp-common:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-exporter-otlp:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-exporter-otlp:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-exporter-sender-okhttp:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-extension-trace-propagators:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-extension-trace-propagators:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-sdk-common:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-sdk-common:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.55.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-sdk-logs:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-sdk-metrics:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-sdk-trace:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.opentelemetry:opentelemetry-sdk:1.49.0=sampleFixture
io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.7.19=sampleFixture
io.projectreactor:reactor-core:3.8.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-config:1.3.10=sampleFixture
io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-core:1.3.10=sampleFixture
io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-formats:1.3.10=sampleFixture
io.prometheus:prometheus-metrics-exposition-formats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-exposition-textformats:1.3.10=sampleFixture
io.prometheus:prometheus-metrics-exposition-textformats:1.4.3=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-model:1.3.10=sampleFixture
io.prometheus:prometheus-metrics-model:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.prometheus:prometheus-metrics-tracer-common:1.3.10=sampleFixture
io.prometheus:prometheus-metrics-tracer-common:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.smallrye:jandex:3.2.0=sampleFixture
io.swagger.core.v3:swagger-annotations-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.29=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:2.1.1=sampleFixture
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.inject:jakarta.inject-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
jakarta.persistence:jakarta.persistence-api:3.1.0=sampleFixture
jakarta.persistence:jakarta.persistence-api:3.2.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.transaction:jakarta.transaction-api:2.0.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.0.2=sampleFixture
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath
jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=sampleFixture
javax.inject:javax.inject:1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.logstash.logback:logstash-logback-encoder:8.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle,testRuntimeClasspath
org.apache.httpcomponents:httpcore:4.4.16=checkstyle,testRuntimeClasspath
org.apache.kafka:kafka-clients:4.1.1=sampleOffTestCompileClasspath,testCompileClasspath
org.apache.logging.log4j:log4j-api:2.24.3=sampleFixture
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.24.3=sampleFixture
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:10.1.55=sampleFixture
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:10.1.55=sampleFixture
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:10.1.55=sampleFixture
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=sampleOffTestCompileClasspath,testCompileClasspath
org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.aspectj:aspectjweaver:1.9.25.1=sampleFixture
org.assertj:assertj-core:3.27.6=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.flywaydb:flyway-core:11.7.2=sampleFixture
org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.flywaydb:flyway-database-postgresql:11.7.2=sampleFixture
org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:jaxb-core:4.0.9=sampleFixture
org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:jaxb-runtime:4.0.9=sampleFixture
org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.glassfish.jaxb:txw2:4.0.9=sampleFixture
org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hibernate.common:hibernate-commons-annotations:7.0.3.Final=sampleFixture
org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.hibernate.orm:hibernate-core:6.6.53.Final=sampleFixture
org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:8.0.3.Final=sampleFixture
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jboss.logging:jboss-logging:3.6.3.Final=sampleFixture
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.25=sampleFixture
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.25=sampleFixture
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.25=sampleFixture
org.jetbrains.kotlin:kotlin-stdlib:1.9.25=sampleFixture
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture
org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor
org.postgresql:postgresql:42.7.11=sampleFixture
org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.reactivestreams:reactive-streams:1.0.4=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.18=sampleFixture
org.slf4j:slf4j-api:2.0.17=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.18=sampleFixture
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springdoc:springdoc-openapi-starter-common:2.8.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.16=sampleFixture
org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-actuator:3.5.16=sampleFixture
org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:3.5.16=sampleFixture
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-actuator:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-jpa:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-json:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter-web:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:3.5.16=sampleFixture
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:3.5.16=sampleFixture
org.springframework.boot:spring-boot:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath
org.springframework.data:spring-data-commons:3.5.13=sampleFixture
org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-jpa:3.5.13=sampleFixture
org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-core:6.5.10=sampleFixture
org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.integration:spring-integration-jdbc:6.5.10=sampleFixture
org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.retry:spring-retry:2.0.13=sampleFixture
org.springframework.security:spring-security-config:6.5.11=sampleFixture
org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:6.5.11=sampleFixture
org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:6.5.11=sampleFixture
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:6.5.11=sampleFixture
org.springframework.security:spring-security-oauth2-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:6.5.11=sampleFixture
org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:6.5.11=sampleFixture
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:6.5.11=sampleFixture
org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:6.2.19=sampleFixture
org.springframework:spring-aop:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aspects:6.2.19=sampleFixture
org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:6.2.19=sampleFixture
org.springframework:spring-beans:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:6.2.19=sampleFixture
org.springframework:spring-context:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:6.2.19=sampleFixture
org.springframework:spring-core:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:6.2.19=sampleFixture
org.springframework:spring-expression:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-jcl:6.2.19=sampleFixture
org.springframework:spring-jdbc:6.2.19=sampleFixture
org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-messaging:6.2.19=sampleFixture
org.springframework:spring-messaging:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-orm:6.2.19=sampleFixture
org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:6.2.19=sampleFixture
org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:6.2.19=sampleFixture
org.springframework:spring-web:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:6.2.19=sampleFixture
org.springframework:spring-webmvc:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-websocket:7.0.1=sampleOffTestCompileClasspath,testCompileClasspath
org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.4=sampleFixture
org.yaml:snakeyaml:2.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
software.amazon.awssdk:annotations:2.30.0=testRuntimeClasspath
software.amazon.awssdk:apache-client:2.30.0=testRuntimeClasspath
software.amazon.awssdk:arns:2.30.0=testRuntimeClasspath
software.amazon.awssdk:auth:2.30.0=testRuntimeClasspath
software.amazon.awssdk:aws-core:2.30.0=testRuntimeClasspath
software.amazon.awssdk:aws-query-protocol:2.30.0=testRuntimeClasspath
software.amazon.awssdk:aws-xml-protocol:2.30.0=testRuntimeClasspath
software.amazon.awssdk:checksums-spi:2.30.0=testRuntimeClasspath
software.amazon.awssdk:checksums:2.30.0=testRuntimeClasspath
software.amazon.awssdk:crt-core:2.30.0=testRuntimeClasspath
software.amazon.awssdk:endpoints-spi:2.30.0=testRuntimeClasspath
software.amazon.awssdk:http-auth-aws-eventstream:2.30.0=testRuntimeClasspath
software.amazon.awssdk:http-auth-aws:2.30.0=testRuntimeClasspath
software.amazon.awssdk:http-auth-spi:2.30.0=testRuntimeClasspath
software.amazon.awssdk:http-auth:2.30.0=testRuntimeClasspath
software.amazon.awssdk:http-client-spi:2.30.0=testRuntimeClasspath
software.amazon.awssdk:identity-spi:2.30.0=testRuntimeClasspath
software.amazon.awssdk:json-utils:2.30.0=testRuntimeClasspath
software.amazon.awssdk:metrics-spi:2.30.0=testRuntimeClasspath
software.amazon.awssdk:netty-nio-client:2.30.0=testRuntimeClasspath
software.amazon.awssdk:profiles:2.30.0=testRuntimeClasspath
software.amazon.awssdk:protocol-core:2.30.0=testRuntimeClasspath
software.amazon.awssdk:regions:2.30.0=testRuntimeClasspath
software.amazon.awssdk:retries-spi:2.30.0=testRuntimeClasspath
software.amazon.awssdk:retries:2.30.0=testRuntimeClasspath
software.amazon.awssdk:s3:2.30.0=testRuntimeClasspath
software.amazon.awssdk:sdk-core:2.30.0=testRuntimeClasspath
software.amazon.awssdk:third-party-jackson-core:2.30.0=testRuntimeClasspath
software.amazon.awssdk:utils:2.30.0=testRuntimeClasspath
software.amazon.eventstream:eventstream:1.0.1=testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=developmentOnly,testAndDevelopmentOnly
@@ -0,0 +1,28 @@
package dev.caskeleton.bootstrap;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication(
scanBasePackages = {
"dev.caskeleton.bootstrap",
"dev.caskeleton.adapter",
"dev.caskeleton.application",
"dev.caskeleton.domain",
"dev.caskeleton.shared"
})
@ConfigurationPropertiesScan(
basePackages = {
"dev.caskeleton.bootstrap",
"dev.caskeleton.adapter",
"dev.caskeleton.application",
"dev.caskeleton.domain",
"dev.caskeleton.shared"
})
public class CaSkeletonApplication {
public static void main(String[] args) {
SpringApplication.run(CaSkeletonApplication.class, args);
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.bootstrap.async;
import dev.caskeleton.shared.concurrency.DomainContextPropagator;
import java.util.Map;
import org.jspecify.annotations.NonNull;
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
/**
* Carries caller-thread context (MDC + domain context) across the executor boundary onto the worker
* thread. The {@code SecurityContext} principal is deliberately NOT propagated here. See README for
* the design rationale.
*/
public final class AsyncContextTaskDecorator implements TaskDecorator {
private final DomainContextPropagator domainContextPropagator;
public AsyncContextTaskDecorator(DomainContextPropagator domainContextPropagator) {
this.domainContextPropagator = domainContextPropagator;
}
@Override
public @NonNull Runnable decorate(@NonNull Runnable runnable) {
// Capture the caller-thread context NOW (at submit time), not when the task runs.
Map<String, String> callerMdc = MDC.getCopyOfContextMap();
// Domain context capture/restore/revert is owned by the shared seam.
Runnable domainWrapped = domainContextPropagator.wrap(runnable);
return () -> {
Map<String, String> previousMdc = MDC.getCopyOfContextMap();
setOrClearMdc(callerMdc);
try {
domainWrapped.run();
} finally {
// Revert so a pooled worker thread does not carry this task's MDC into the next.
setOrClearMdc(previousMdc);
}
};
}
private static void setOrClearMdc(Map<String, String> context) {
if (context != null) {
MDC.setContextMap(context);
} else {
MDC.clear();
}
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.bootstrap.async;
import dev.caskeleton.shared.concurrency.DomainContextPropagator;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* Wires the async executor. Replaces Spring Boot's auto-configured {@code applicationTaskExecutor}
* with a bounded pool; naming the bean {@value #EXECUTOR_BEAN_NAME} backs off Boot's auto-config
* and makes this the executor {@code @Async} resolves to. See README for the design rationale.
*/
@Configuration
@EnableAsync
@EnableConfigurationProperties(AsyncExecutorSettings.class)
public class AsyncExecutorConfig {
/** Spring's default {@code @Async} executor bean name (Boot's auto-config backs off). */
public static final String EXECUTOR_BEAN_NAME = "applicationTaskExecutor";
/** Shutdown await: container app-shutdown budget (20s) minus a 1s cleanup margin. */
static final int AWAIT_TERMINATION_SECONDS = 19;
@Bean
TaskDecorator asyncContextTaskDecorator(DomainContextPropagator domainContextPropagator) {
return new AsyncContextTaskDecorator(domainContextPropagator);
}
@Bean
BackgroundJobMetrics backgroundJobMetrics(ObjectProvider<MeterRegistry> meterRegistryProvider) {
return new BackgroundJobMetrics(meterRegistryProvider);
}
@Bean(name = EXECUTOR_BEAN_NAME)
@Primary
ThreadPoolTaskExecutor applicationTaskExecutor(
AsyncExecutorSettings settings,
TaskDecorator asyncContextTaskDecorator,
BackgroundJobMetrics metrics) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("app-async-");
executor.setCorePoolSize(settings.coreSize());
executor.setMaxPoolSize(settings.maxSize());
executor.setQueueCapacity(settings.queueCapacity());
executor.setTaskDecorator(asyncContextTaskDecorator);
executor.setRejectedExecutionHandler(new LoggingAbortPolicy(EXECUTOR_BEAN_NAME, metrics));
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS);
executor.initialize();
metrics.registerSaturationGauge(
EXECUTOR_BEAN_NAME, () -> executor.getThreadPoolExecutor().getQueue().size());
return executor;
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.bootstrap.async;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Async executor pool sizing knobs, bound from {@code ca-skeleton.async.executor.*}. Rejects {@code
* Integer.MAX_VALUE} as a queue capacity so the queue stays bounded. See README for the design
* rationale.
*
* @param coreSize always-alive worker count (≥ 1)
* @param maxSize hard ceiling on workers (≥ 1 and ≥ {@code coreSize})
* @param queueCapacity bounded backlog depth (1 ≤ capacity &lt; {@code Integer.MAX_VALUE})
*/
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.async.executor")
public record AsyncExecutorSettings(Integer coreSize, Integer maxSize, Integer queueCapacity) {
private static final int DEFAULT_CORE_SIZE = 10;
private static final int DEFAULT_MAX_SIZE = 50;
private static final int DEFAULT_QUEUE_CAPACITY = 200;
public AsyncExecutorSettings {
if (coreSize == null) {
coreSize = DEFAULT_CORE_SIZE;
} else if (coreSize < 1) {
throw StartupFailures.envValidation(
"APP_ASYNC_EXECUTOR_CORE_SIZE (ca-skeleton.async.executor.core-size) must be >= 1 "
+ "(positive_int), was "
+ coreSize);
}
if (queueCapacity == null) {
queueCapacity = DEFAULT_QUEUE_CAPACITY;
} else if (queueCapacity < 1) {
throw StartupFailures.envValidation(
"APP_ASYNC_EXECUTOR_QUEUE_CAPACITY (ca-skeleton.async.executor.queue-capacity) must be >= 1 "
+ "(positive_int_bounded), was "
+ queueCapacity);
} else if (queueCapacity == Integer.MAX_VALUE) {
throw StartupFailures.envValidation(
"APP_ASYNC_EXECUTOR_QUEUE_CAPACITY (ca-skeleton.async.executor.queue-capacity) must be bounded "
+ "(< Integer.MAX_VALUE) — an unbounded queue makes max-size unreachable and is "
+ "forbidden (D7, positive_int_bounded)");
}
if (maxSize == null) {
maxSize = DEFAULT_MAX_SIZE;
} else if (maxSize < 1) {
throw StartupFailures.envValidation(
"APP_ASYNC_EXECUTOR_MAX_SIZE (ca-skeleton.async.executor.max-size) must be >= 1 "
+ "(positive_int_ge_core), was "
+ maxSize);
}
// cross-field invariant: max must not be below core.
if (maxSize < coreSize) {
throw StartupFailures.envValidation(
"APP_ASYNC_EXECUTOR_MAX_SIZE (ca-skeleton.async.executor.max-size="
+ maxSize
+ ") must be >= APP_ASYNC_EXECUTOR_CORE_SIZE (core-size="
+ coreSize
+ ") (positive_int_ge_core)");
}
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.bootstrap.async;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import java.util.function.DoubleSupplier;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
/**
* Background-job / async-executor metric recorder. The {@code job.*} recorders are the retry/DLQ
* vocabulary for future retry carriers to consume. With no {@code MeterRegistry} bean (no Actuator
* on the classpath) every operation is a no-op. See README for the design rationale.
*/
public final class BackgroundJobMetrics {
private static final Logger log = LoggerFactory.getLogger(BackgroundJobMetrics.class);
static final String EXECUTOR_SATURATION = "executor.saturation";
static final String EXECUTOR_REJECTED = "executor.rejected.total";
static final String JOB_RETRY = "job.retry.total";
static final String JOB_DLQ = "job.dlq.total";
static final String TAG_EXECUTOR_NAME = "executor_name";
static final String TAG_POLICY = "policy";
static final String TAG_JOB_NAME = "job_name";
static final String TAG_OUTCOME = "outcome";
private final MeterRegistry registry; // null when no Actuator on classpath
public BackgroundJobMetrics(ObjectProvider<MeterRegistry> meterRegistryProvider) {
this.registry = meterRegistryProvider.getIfAvailable();
}
/**
* Registers the {@code executor.saturation} gauge for {@code executorName}, reading the current
* queue depth from {@code queueDepth} on each scrape. No-op without a registry.
*/
public void registerSaturationGauge(String executorName, DoubleSupplier queueDepth) {
if (registry == null) {
return;
}
Gauge.builder(EXECUTOR_SATURATION, queueDepth, DoubleSupplier::getAsDouble)
.tag(TAG_EXECUTOR_NAME, executorName)
.description("Current async executor queue depth")
.register(registry);
}
public void recordRejection(String executorName, String policy) {
increment(
EXECUTOR_REJECTED,
() ->
Counter.builder(EXECUTOR_REJECTED)
.tag(TAG_EXECUTOR_NAME, executorName)
.tag(TAG_POLICY, policy));
}
public void recordRetryOutcome(String jobName, RetryOutcome outcome) {
increment(
JOB_RETRY,
() ->
Counter.builder(JOB_RETRY).tag(TAG_JOB_NAME, jobName).tag(TAG_OUTCOME, outcome.name()));
}
public void recordDeadLetter(String jobName) {
increment(JOB_DLQ, () -> Counter.builder(JOB_DLQ).tag(TAG_JOB_NAME, jobName));
}
private void increment(String meterName, Supplier<Counter.Builder> builder) {
if (registry == null) {
return;
}
try {
builder.get().register(registry).increment();
} catch (RuntimeException ex) {
log.warn("background-job metrics: failed to record counter {}", meterName, ex);
}
}
/** Retry-cycle outcome tag values for {@code job.retry.total} (bounded to 4). */
public enum RetryOutcome {
SUCCESS,
RETRY,
EXHAUSTED,
DLQ
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.bootstrap.async;
import static net.logstash.logback.argument.StructuredArguments.kv;
import dev.caskeleton.shared.error.OperationalError;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The async executor's saturation policy. An {@code AbortPolicy} that logs and increments a
* rejection counter, then re-throws {@link RejectedExecutionException} so a fire-and-forget
* {@code @Async} caller's rejection is not silently swallowed. See README for the design rationale.
*/
public final class LoggingAbortPolicy implements RejectedExecutionHandler {
private static final Logger log = LoggerFactory.getLogger(LoggingAbortPolicy.class);
/** Tag value for {@code executor.rejected.total{policy}} (metrics.yaml allowed set). */
static final String POLICY = "AbortPolicy";
private final String executorName;
private final BackgroundJobMetrics metrics;
public LoggingAbortPolicy(String executorName, BackgroundJobMetrics metrics) {
this.executorName = executorName;
this.metrics = metrics;
}
@Override
public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
metrics.recordRejection(executorName, POLICY);
log.error(
"async executor rejected task — pool saturated (core/max/queue exhausted): {} {} {} {} {}",
kv("error.code", OperationalError.JOB_EXECUTOR_REJECTED.code()),
kv("error.category", OperationalError.JOB_EXECUTOR_REJECTED.category().name()),
kv("executor_name", executorName),
kv("policy", POLICY),
kv("queue_size", executor.getQueue().size()));
// Preserve AbortPolicy semantics: the caller must see the rejection.
throw new RejectedExecutionException(
"Task "
+ task
+ " rejected from async executor '"
+ executorName
+ "' ("
+ OperationalError.JOB_EXECUTOR_REJECTED.code()
+ ")");
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.bootstrap.concurrency;
import dev.caskeleton.shared.concurrency.DomainContextPropagator;
import dev.caskeleton.shared.concurrency.DomainContextPropagatorFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** Wires the {@link DomainContextPropagator} bean. See README for the design rationale. */
@Configuration
@EnableConfigurationProperties(DomainContextSettings.class)
public class DomainContextConfig {
@Bean
public DomainContextPropagator domainContextPropagator(DomainContextSettings properties) {
return DomainContextPropagatorFactory.create(properties.strategy());
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.bootstrap.concurrency;
import dev.caskeleton.shared.concurrency.DomainContextStrategy;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Domain-context propagation strategy selection. See README for the design rationale.
*
* @param strategy the propagation strategy; {@code null} → {@code THREAD_LOCAL}
*/
@ConfigurationProperties(prefix = "ca-skeleton.domain-context")
public record DomainContextSettings(DomainContextStrategy strategy) {
public DomainContextSettings {
if (strategy == null) {
strategy = DomainContextStrategy.THREAD_LOCAL;
}
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.bootstrap.idempotency;
import dev.caskeleton.application.idempotency.IdempotencyExecutor;
import dev.caskeleton.application.idempotency.IdempotencyStorePort;
import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Wires idempotency execution. {@code @EnableScheduling} activates the {@code IdempotencyReaper}'s
* scheduled purge. See README for the design rationale.
*/
@Configuration
@EnableScheduling
public class IdempotencyConfig {
@Bean
public Clock systemClock() {
return Clock.systemUTC();
}
@Bean
public IdempotencyExecutor idempotencyExecutor(
IdempotencyStorePort store, Clock clock, IdempotencySettings properties) {
return new IdempotencyExecutor(store, clock, properties.ttl());
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.bootstrap.idempotency;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Idempotency runtime knobs. The {@code <= 72h} TTL cap is enforced fail-fast in the compact
* constructor. See README for the design rationale.
*
* @param ttl default idempotency record TTL (≤ 72h)
* @param reaperInterval how often the expired-record reaper runs
*/
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.idempotency")
public record IdempotencySettings(Duration ttl, Duration reaperInterval) {
private static final Duration MAX_TTL = Duration.ofHours(72);
public IdempotencySettings {
if (ttl == null) {
ttl = Duration.ofHours(24);
}
if (ttl.isZero() || ttl.isNegative()) {
throw StartupFailures.envValidation(
"APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be positive, was " + ttl);
}
if (ttl.compareTo(MAX_TTL) > 0) {
throw StartupFailures.envValidation(
"APP_IDEMPOTENCY_TTL (ca-skeleton.idempotency.ttl) must be <= 72h (D6), was " + ttl);
}
if (reaperInterval == null || reaperInterval.isZero() || reaperInterval.isNegative()) {
reaperInterval = Duration.ofMinutes(10);
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.bootstrap.lock;
import dev.caskeleton.application.lock.DistributedLockPort;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* Composition-root wiring for the distributed-lock metrics decorator. See README for the design
* rationale.
*/
@Configuration(proxyBeanMethods = false)
public class DistributedLockConfig {
/**
* The bean name {@code "distributedLockProvider"} is the contract with {@code
* StartupSafetyValidator} — do not rename. See README for the design rationale.
*/
@Bean(name = "distributedLockProvider")
@Primary
@ConditionalOnProperty(
prefix = "ca-skeleton.runtime",
name = "multi-instance-enabled",
havingValue = "true")
DistributedLockPort distributedLockProvider(
@Qualifier("jdbcDistributedLock") DistributedLockPort jdbcDistributedLock,
ObjectProvider<MeterRegistry> meterRegistry) {
return new MeteredDistributedLockPort(jdbcDistributedLock, meterRegistry);
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.bootstrap.lock;
import dev.caskeleton.application.lock.DistributedLock;
import dev.caskeleton.application.lock.DistributedLockPort;
import dev.caskeleton.application.lock.LockAcquisitionTimeoutException;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Duration;
import java.util.ConcurrentModificationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
/**
* Metrics decorator for {@link DistributedLockPort}. Metric failures are logged-and-swallowed so
* they never affect the lock path. See README for the design rationale.
*/
public final class MeteredDistributedLockPort implements DistributedLockPort {
private static final Logger log = LoggerFactory.getLogger(MeteredDistributedLockPort.class);
static final String LOCK_ACQUISITION = "lock.acquisition";
static final String TAG_OUTCOME = "outcome";
static final String OUTCOME_ACQUIRED = "acquired";
static final String OUTCOME_TIMEOUT = "timeout";
static final String OUTCOME_ERROR = "error";
static final String LOCK_LEASE_EXPIRED = "lock.lease.expired";
private final DistributedLockPort delegate;
private final MeterRegistry registry; // null when no MeterRegistry on classpath
public MeteredDistributedLockPort(
DistributedLockPort delegate, ObjectProvider<MeterRegistry> meterRegistryProvider) {
this.delegate = delegate;
this.registry = meterRegistryProvider.getIfAvailable();
}
@Override
public DistributedLock tryAcquire(String key, Duration waitTime, Duration leaseTtl) {
try {
DistributedLock handle = delegate.tryAcquire(key, waitTime, leaseTtl);
increment(OUTCOME_ACQUIRED);
return () -> closeHandlingLeaseExpiry(key, handle);
} catch (LockAcquisitionTimeoutException e) {
increment(OUTCOME_TIMEOUT);
throw e;
} catch (RuntimeException e) {
increment(OUTCOME_ERROR);
throw e;
}
}
/**
* Absorbs a lease-expiry {@link ConcurrentModificationException} on close so it does not disrupt
* the caller's {@code finally} block; any other exception propagates unchanged. See README for
* the design rationale.
*/
private void closeHandlingLeaseExpiry(String key, DistributedLock handle) {
try {
handle.close();
} catch (ConcurrentModificationException e) {
// Lease expired before release: the lock row was reclaimed, so another instance may
// have entered the critical section. Surface it (log + metric) but return normally.
log.warn(
"distributed lock '{}' had already been released by lease expiry before close() "
+ "— another instance may have entered the critical section (D6 efficiency-lock boundary)",
key,
e);
incrementLeaseExpired();
}
}
private void increment(String outcome) {
if (registry == null) {
return;
}
try {
Counter.builder(LOCK_ACQUISITION).tag(TAG_OUTCOME, outcome).register(registry).increment();
} catch (RuntimeException ex) {
log.warn(
"distributed-lock metrics: failed to record counter {}[outcome={}]",
LOCK_ACQUISITION,
outcome,
ex);
}
}
private void incrementLeaseExpired() {
if (registry == null) {
return;
}
try {
Counter.builder(LOCK_LEASE_EXPIRED).register(registry).increment();
} catch (RuntimeException ex) {
log.warn("distributed-lock metrics: failed to record counter {}", LOCK_LEASE_EXPIRED, ex);
}
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.bootstrap.logging;
import com.fasterxml.jackson.core.JsonStreamContext;
import java.util.List;
import java.util.regex.Pattern;
import net.logstash.logback.mask.ValueMasker;
/**
* SSOT for secret-masking regex rules (shared by the JSON-encoder and pattern-layout paths). See
* README for the design rationale.
*/
public final class LogMaskingPatterns {
public static final String MASK = "****";
private record Rule(Pattern pattern, String replacement) {}
// Value char class shared by the key=value / authorization rules: a secret token runs until
// the next whitespace, quote, comma, ampersand, or closing brace (JSON / query / kv delimiters).
private static final String VALUE = "[^\\s\"',&}]+";
private static final String SEP = "[\"']?\\s*[:=]\\s*[\"']?";
private static final List<Rule> RULES =
List.of(
// 1. key=value / "key":"value" secrets — keep the key + separator, mask the value.
new Rule(
Pattern.compile(
"(?i)(password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token"
+ "|refresh[_-]?token|client[_-]?secret)("
+ SEP
+ ")("
+ VALUE
+ ")"),
"$1$2" + MASK),
// 2. Authorization header (optional auth scheme kept) — mask the credential.
new Rule(
Pattern.compile(
"(?i)(authorization" + SEP + ")((?:bearer|basic|negotiate)\\s+)?(" + VALUE + ")"),
"$1$2" + MASK),
// 3. Standalone bearer token not preceded by an "authorization" key.
new Rule(Pattern.compile("(?i)(bearer\\s+)([A-Za-z0-9._~+/=-]{8,})"), "$1" + MASK));
private LogMaskingPatterns() {}
/**
* Returns {@code input} unchanged (same reference) when nothing matched; never returns {@code
* null} for a non-null argument.
*/
public static String mask(String input) {
if (input == null || input.isEmpty()) {
return input;
}
String out = input;
for (Rule rule : RULES) {
out = rule.pattern().matcher(out).replaceAll(rule.replacement());
}
return out;
}
/**
* Adapts {@link #mask(String)} to the logstash {@link ValueMasker} SPI. Returns the original
* object reference when nothing was masked so the decorator writes the value untouched.
*/
public static ValueMasker valueMasker() {
return (JsonStreamContext context, Object value) -> {
if (value instanceof CharSequence cs) {
String original = cs.toString();
String masked = mask(original);
return masked.equals(original) ? value : masked;
}
return value;
};
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.bootstrap.logging;
import ch.qos.logback.classic.AsyncAppender;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import io.micrometer.core.instrument.Metrics;
/**
* {@link AsyncAppender} that counts log events dropped under back-pressure. Published through
* {@link Metrics#globalRegistry} because Logback initializes before the Spring context. See README
* for the design rationale.
*/
public class MetricsAsyncAppender extends AsyncAppender {
static final String DROPPED_METER = "log.appender.dropped.total";
@Override
protected void append(ILoggingEvent eventObject) {
if (isQueueBelowDiscardingThreshold() && isDiscardable(eventObject)) {
Level level = eventObject.getLevel();
if (level == Level.INFO || level == Level.DEBUG) {
Metrics.counter(DROPPED_METER, "appender", appenderName(), "level", level.toString())
.increment();
}
}
super.append(eventObject);
}
private String appenderName() {
String name = getName();
return name == null ? "unknown" : name;
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.bootstrap.logging;
import dev.caskeleton.adapter.outbound.identifier.HmacUserPrincipalPseudonymizer;
import dev.caskeleton.application.observability.UserPrincipalPseudonymizerPort;
import dev.caskeleton.bootstrap.settings.PrivacySettings;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Composition-root wiring for {@code user_principal} pseudonymization.
* {@code @ConditionalOnMissingBean} lets a forking project substitute its own pseudonymizer. See
* README for the design rationale.
*/
@Configuration
@EnableConfigurationProperties(PrivacySettings.class)
public class PseudonymizationConfig {
@Bean
@ConditionalOnMissingBean
UserPrincipalPseudonymizerPort userPrincipalPseudonymizer(PrivacySettings privacySettings) {
return new HmacUserPrincipalPseudonymizer(privacySettings.saltBytes());
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.bootstrap.logging;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.turbo.TurboFilter;
import ch.qos.logback.core.spi.FilterReply;
import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Marker;
/**
* Level-aware log sampler. {@code WARN}/{@code ERROR} are never sampled because they are
* diagnostic/incident signals that must be guaranteed; only {@code INFO} and below are sampled. See
* README for the design rationale.
*/
public class SamplingTurboFilter extends TurboFilter {
private static final double KEEP_ALL = 1.0d;
private volatile double rate = KEEP_ALL;
/** Logback Joran setter — {@code <rate>${LOG_SAMPLING_RATE}</rate>}. */
public void setRate(double rate) {
this.rate = rate;
}
public double getRate() {
return rate;
}
@Override
public void start() {
if (rate < 0.0d || rate > 1.0d || Double.isNaN(rate)) {
addWarn(
"APP_LOG_SAMPLING_RATE must be in [0.0, 1.0] (got " + rate + "); using 1.0 (keep all)");
rate = KEEP_ALL;
}
super.start();
}
@Override
public FilterReply decide(
Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) {
if (!isStarted() || level == null) {
return FilterReply.NEUTRAL;
}
if (level.toInt() >= Level.WARN_INT) {
return FilterReply.NEUTRAL;
}
// <= INFO: keep with probability `rate`.
if (rate >= KEEP_ALL) {
return FilterReply.NEUTRAL;
}
if (rate <= 0.0d) {
return FilterReply.DENY;
}
return ThreadLocalRandom.current().nextDouble() < rate ? FilterReply.NEUTRAL : FilterReply.DENY;
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.bootstrap.logging;
import net.logstash.logback.mask.MaskingJsonGeneratorDecorator;
/**
* JSON-encoder arm of secret redaction ({@link LogMaskingPatterns} catalog). Masks at
* JSON-generation time, so it covers every emitted string value (message, MDC, stack trace). See
* README for the design rationale.
*/
public class SecretMaskingJsonGeneratorDecorator extends MaskingJsonGeneratorDecorator {
public SecretMaskingJsonGeneratorDecorator() {
setDefaultMask(LogMaskingPatterns.MASK);
addValueMasker(LogMaskingPatterns.valueMasker());
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.bootstrap.logging;
import ch.qos.logback.classic.pattern.MessageConverter;
import ch.qos.logback.classic.spi.ILoggingEvent;
/**
* {@code PatternLayout} arm of secret redaction ({@link LogMaskingPatterns} catalog). Registered as
* the {@code %maskedMsg} conversion word; unlike the JSON arm, only the message body is masked
* here. See README for the design rationale.
*/
public class SecretMaskingMessageConverter extends MessageConverter {
@Override
public String convert(ILoggingEvent event) {
return LogMaskingPatterns.mask(super.convert(event));
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.bootstrap.logging;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.turbo.TurboFilter;
import ch.qos.logback.core.spi.FilterReply;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailureLogState;
import java.util.Set;
import org.slf4j.Marker;
/**
* Drops Spring Boot's generic duplicate startup-failure logs after {@code StartupFailures} has
* already emitted the canonical structured record.
*/
public class StartupFailureSpringBootLogFilter extends TurboFilter {
private static final String SPRING_APPLICATION_LOGGER =
"org.springframework.boot.SpringApplication";
private static final String FAILURE_ANALYSIS_LOGGER =
"org.springframework.boot.diagnostics.LoggingFailureAnalysisReporter";
private static final String SPRING_LOGGER_PREFIX = "org.springframework.";
private static final String CONTEXT_REFRESH_CANCELLED_PREFIX =
"Exception encountered during context initialization - cancelling refresh attempt";
private static final Set<String> DUPLICATE_MESSAGES =
Set.of("Application run failed", "Unable to close ApplicationContext");
@Override
public FilterReply decide(
Marker marker, Logger logger, Level level, String format, Object[] params, Throwable t) {
if (!isStarted() || !StartupFailureLogState.startupFailureReported()) {
return FilterReply.NEUTRAL;
}
if (logger == null) {
return FilterReply.NEUTRAL;
}
String loggerName = logger.getName();
if (SPRING_APPLICATION_LOGGER.equals(loggerName)
&& format != null
&& DUPLICATE_MESSAGES.contains(format)) {
return FilterReply.DENY;
}
if (FAILURE_ANALYSIS_LOGGER.equals(loggerName)) {
return FilterReply.DENY;
}
if (loggerName.startsWith(SPRING_LOGGER_PREFIX)
&& format != null
&& format.startsWith(CONTEXT_REFRESH_CANCELLED_PREFIX)) {
return FilterReply.DENY;
}
return FilterReply.NEUTRAL;
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.bootstrap.management.security;
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
/**
* Actuator endpoint security chain, ordered ahead of the main app chain. Security matching is done
* by endpoint id string only, never by health endpoint internals. See README for the design
* rationale.
*/
@Configuration
public class ManagementSecurityConfig {
@Bean
@Order(0)
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher(EndpointRequest.toAnyEndpoint())
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Answer 401 (credentials required) instead of the default 403; no interactive login.
.exceptionHandling(
ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(
auth ->
auth
// permit-all for probes and Prometheus scrape.
.requestMatchers(EndpointRequest.to("health", "info", "prometheus"))
.permitAll()
.requestMatchers(HttpMethod.POST, "/actuator/loggers/**")
.denyAll()
.requestMatchers(HttpMethod.DELETE, "/actuator/loggers/**")
.denyAll()
.anyRequest()
.authenticated());
return http.build();
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.bootstrap.metrics;
import dev.caskeleton.shared.metrics.ForbiddenMetricTags;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.Tag;
import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.config.MeterFilterReply;
/**
* {@link MeterFilter} that blocks high-cardinality label keys at runtime. See README for the design
* rationale.
*
* @see ForbiddenMetricTags
* @see MetricsContractConfig
*/
public final class MetricsCardinalityMeterFilter implements MeterFilter {
public MetricsCardinalityMeterFilter() {
// public no-arg constructor — required by MetricsContractConfig.install()
}
@Override
public MeterFilterReply accept(Meter.Id id) {
for (Tag tag : id.getTags()) {
if (ForbiddenMetricTags.isForbidden(tag.getKey())) {
return MeterFilterReply.DENY;
}
}
return MeterFilterReply.NEUTRAL;
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.bootstrap.metrics;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Configuration;
/**
* Installs the metrics MeterFilters onto the MeterRegistry (deny-list before histogram config). See
* README for the design rationale.
*
* @see MetricsCardinalityMeterFilter
* @see MetricsDistributionMeterFilter
*/
@Configuration
public class MetricsContractConfig {
private static final Logger log = LoggerFactory.getLogger(MetricsContractConfig.class);
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
public MetricsContractConfig(ObjectProvider<MeterRegistry> meterRegistryProvider) {
this.meterRegistryProvider = meterRegistryProvider;
}
@PostConstruct
void installFilters() {
MeterRegistry registry = meterRegistryProvider.getIfAvailable();
if (registry == null) {
log.debug(
"metrics-alerting-contract: no MeterRegistry available; "
+ "MeterFilter install skipped (no Actuator on classpath)");
return;
}
install(registry);
}
/**
* Public and static so contract tests can drive it directly against a {@link
* io.micrometer.core.instrument.simple.SimpleMeterRegistry} without a Spring context. See README
* for the design rationale.
*
* @param registry the registry to configure; must not be {@code null}
*/
public static void install(MeterRegistry registry) {
registry.config().meterFilter(new MetricsCardinalityMeterFilter());
registry.config().meterFilter(new MetricsDistributionMeterFilter());
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.bootstrap.metrics;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.config.MeterFilter;
import io.micrometer.core.instrument.distribution.DistributionStatisticConfig;
import java.time.Duration;
import java.util.Set;
/**
* {@link MeterFilter} that applies SLO-driven histogram and percentile configuration to a fixed set
* of owned timer metrics. See README for the design rationale.
*
* @see MetricsContractConfig
* @see MetricsCardinalityMeterFilter
*/
public final class MetricsDistributionMeterFilter implements MeterFilter {
private static final Set<String> SLO_DRIVEN_TIMERS =
Set.of(
"http.server.requests",
"http.server.requests.latency",
"dependency.client.requests",
"db.query.duration",
"jvm.gc.pause");
public MetricsDistributionMeterFilter() {
// public no-arg constructor — required by MetricsContractConfig.install()
}
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
if (!SLO_DRIVEN_TIMERS.contains(id.getName())) {
return config;
}
return DistributionStatisticConfig.builder()
.percentilesHistogram(true)
.percentiles(0.5, 0.9, 0.95, 0.99)
.serviceLevelObjectives(
(double) Duration.ofMillis(100).toNanos(),
(double) Duration.ofMillis(500).toNanos(),
(double) Duration.ofSeconds(1).toNanos(),
(double) Duration.ofSeconds(5).toNanos())
.minimumExpectedValue((double) Duration.ofMillis(1).toNanos())
.maximumExpectedValue((double) Duration.ofSeconds(10).toNanos())
.build()
.merge(config);
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.bootstrap.outbox;
import dev.caskeleton.application.outbox.OutboxBackoffPolicy;
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
import dev.caskeleton.application.outbox.OutboxStorePort;
import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase;
import dev.caskeleton.application.transaction.TransactionPort;
import io.micrometer.core.instrument.MeterRegistry;
import java.time.Clock;
import java.util.SplittableRandom;
import java.util.random.RandomGenerator;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Composition-root wiring for the transactional outbox relay: assembles the relay use case manually
* and registers the leader-election token, metrics, and scheduler. See README for the design
* rationale.
*/
@Configuration
@EnableConfigurationProperties(OutboxSettings.class)
public class OutboxConfig {
/**
* Relay use case is assembled manually here (not a bean) from {@link OutboxSettings} values. See
* README for the design rationale.
*/
@Bean
@ConditionalOnProperty(
name = "ca-skeleton.outbox.relay-enabled",
havingValue = "true",
matchIfMissing = true)
public OutboxRelayScheduler outboxRelayScheduler(
OutboxStorePort store,
OutboxMessagePublishPort publishPort,
TransactionPort tx,
Clock clock,
RandomGenerator outboxRandomGenerator,
OutboxSettings properties,
OutboxMetrics metrics) {
PublishPendingOutboxEventsUseCase relayUseCase =
new PublishPendingOutboxEventsUseCase(
store,
publishPort,
tx,
new OutboxBackoffPolicy(outboxRandomGenerator),
clock,
properties.batchSize(),
properties.inFlightTimeout());
return new OutboxRelayScheduler(relayUseCase, metrics, clock);
}
/** Uses a {@code java.base} RNG so the relay also starts on the slim Temurin JRE image. */
@Bean
public RandomGenerator outboxRandomGenerator() {
return new SplittableRandom();
}
/**
* Registered unconditionally so the SKIP LOCKED leadership mechanism is always present. See
* README for the design rationale.
*/
@Bean(name = "outboxLeaderElection")
public OutboxLeaderElectionToken outboxLeaderElection() {
return new OutboxLeaderElectionToken();
}
/** Outbox metrics collector; no-op when {@link MeterRegistry} is absent. */
@Bean
public OutboxMetrics outboxMetrics(
OutboxStorePort store, ObjectProvider<MeterRegistry> meterRegistryProvider) {
return new OutboxMetrics(store, meterRegistryProvider);
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.bootstrap.outbox;
/**
* Marker bean: presence in the context satisfies the multi-instance coordination requirement (no
* logic; SKIP LOCKED is the leadership mechanism). See README for the design rationale.
*/
public final class OutboxLeaderElectionToken {
private static final String STRATEGY_DESCRIPTION =
"SKIP LOCKED claim — each relay instance claims a disjoint row partition; "
+ "no external coordinator required (PostgreSQL FOR UPDATE SKIP LOCKED, I3/D8)";
public String strategyDescription() {
return STRATEGY_DESCRIPTION;
}
@Override
public String toString() {
return "OutboxLeaderElectionToken{strategy=SKIP_LOCKED}";
}
}
@@ -0,0 +1,117 @@
package dev.caskeleton.bootstrap.outbox;
import dev.caskeleton.application.outbox.OutboxEventStatus;
import dev.caskeleton.application.outbox.OutboxRelayResult;
import dev.caskeleton.application.outbox.OutboxStorePort;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.MultiGauge;
import io.micrometer.core.instrument.Tags;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
/**
* Outbox relay metric collector. No-op when no {@link MeterRegistry} is present (resolved via
* {@link ObjectProvider}). See README for the design rationale.
*/
public final class OutboxMetrics {
private static final Logger log = LoggerFactory.getLogger(OutboxMetrics.class);
private static final String COUNTER_NAME = "outbox.publisher.published.total";
private static final String SIZE_GAUGE = "outbox.pending.size";
private static final String LAG_GAUGE = "outbox.publisher.lag";
private final OutboxStorePort store;
private final MeterRegistry registry; // null when no Actuator on classpath
private final MultiGauge pendingSizeGauge;
private final MultiGauge publisherLagGauge;
public OutboxMetrics(OutboxStorePort store, ObjectProvider<MeterRegistry> meterRegistryProvider) {
this.store = store;
this.registry = meterRegistryProvider.getIfAvailable();
if (this.registry != null) {
this.pendingSizeGauge =
MultiGauge.builder(SIZE_GAUGE)
.description("Number of outbox rows grouped by status")
.register(this.registry);
this.publisherLagGauge =
MultiGauge.builder(LAG_GAUGE)
.description("Age in seconds of the oldest unpublished row per event type")
.baseUnit("seconds")
.register(this.registry);
} else {
this.pendingSizeGauge = null;
this.publisherLagGauge = null;
}
}
/** Counts each terminal outcome (PUBLISHED, FAILED, DEAD; not IN_FLIGHT). */
public void recordRelayResult(OutboxRelayResult result) {
if (registry == null || result == null) {
return;
}
for (OutboxRelayResult.EventOutcome eo : result.outcomes()) {
try {
Counter.builder(COUNTER_NAME)
.tag("event_type", eo.eventType())
.tag("outcome", eo.outcome().name())
.register(registry)
.increment();
} catch (Exception ex) {
log.warn(
"outbox metrics: failed to record counter for eventType={} outcome={}",
eo.eventType(),
eo.outcome(),
ex);
}
}
}
/**
* Uses {@code overwrite=true} so stale time-series tags from removed event types are replaced
* rather than left dangling.
*
* @param now current wall-clock instant (for the lag gauge age computation)
*/
public void refresh(Instant now) {
if (registry == null) {
return;
}
refreshPendingSize();
refreshPublisherLag(now);
}
private void refreshPendingSize() {
try {
Map<OutboxEventStatus, Long> counts = store.countByStatus();
List<MultiGauge.Row<?>> rows = new ArrayList<>(OutboxEventStatus.values().length);
for (OutboxEventStatus status : OutboxEventStatus.values()) {
long count = counts.getOrDefault(status, 0L);
rows.add(MultiGauge.Row.of(Tags.of("status", status.name()), count));
}
pendingSizeGauge.register(rows, true);
} catch (Exception ex) {
log.warn("outbox metrics: failed to refresh pending-size gauge", ex);
}
}
private void refreshPublisherLag(Instant now) {
try {
Map<String, Long> lagByType = store.oldestUnpublishedAgeSecondsByEventType(now);
List<MultiGauge.Row<?>> rows = new ArrayList<>(lagByType.size());
for (Map.Entry<String, Long> entry : lagByType.entrySet()) {
rows.add(MultiGauge.Row.of(Tags.of("event_type", entry.getKey()), entry.getValue()));
}
publisherLagGauge.register(rows, true);
} catch (Exception ex) {
log.warn("outbox metrics: failed to refresh publisher-lag gauge", ex);
}
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.bootstrap.outbox;
import dev.caskeleton.application.outbox.OutboxRelayResult;
import dev.caskeleton.application.outbox.PublishPendingOutboxEventsCommand;
import dev.caskeleton.application.outbox.PublishPendingOutboxEventsUseCase;
import java.time.Clock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
/**
* Thin bootstrap scheduler that drives the outbox relay use case on a fixed-delay polling loop.
* Registered by {@code OutboxConfig}. See README for the design rationale.
*/
public class OutboxRelayScheduler {
private static final Logger log = LoggerFactory.getLogger(OutboxRelayScheduler.class);
private final PublishPendingOutboxEventsUseCase relayUseCase;
private final OutboxMetrics metrics;
private final Clock clock;
public OutboxRelayScheduler(
PublishPendingOutboxEventsUseCase relayUseCase, OutboxMetrics metrics, Clock clock) {
this.relayUseCase = relayUseCase;
this.metrics = metrics;
this.clock = clock;
}
/**
* Unexpected exceptions are caught (not propagated) so the scheduler thread stays alive for the
* next tick.
*/
@Scheduled(fixedDelayString = "${ca-skeleton.outbox.poll-interval:PT5S}")
public void relay() {
try {
OutboxRelayResult result = relayUseCase.handle(PublishPendingOutboxEventsCommand.INSTANCE);
if (result.claimedCount() > 0) {
log.debug(
"outbox relay cycle: claimed={} outcomes={}",
result.claimedCount(),
result.outcomes().size());
}
metrics.recordRelayResult(result);
metrics.refresh(clock.instant());
} catch (Exception ex) {
log.error(
"outbox relay scheduler: unexpected error in relay cycle — "
+ "relay will retry on the next tick",
ex);
}
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.bootstrap.outbox;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Outbox relay runtime knobs bound from {@code ca-skeleton.outbox.*} (literal defaults, no env
* placeholders). See README for the design rationale.
*
* @param relayEnabled whether the relay scheduler is enabled; default {@code true}
* @param pollInterval how often the relay polls the outbox table; default {@code PT5S}
* @param batchSize maximum rows claimed per relay cycle; default {@code 20}
* @param inFlightTimeout in-flight orphan visibility window; default {@code PT5M}
* @param reaperInterval how often the reaper purges old PUBLISHED rows; default {@code PT10M}
* @param publishedRetention how long PUBLISHED rows are kept before the reaper deletes them;
* default {@code P7D}
*/
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.outbox")
public record OutboxSettings(
Boolean relayEnabled,
Duration pollInterval,
Integer batchSize,
Duration inFlightTimeout,
Duration reaperInterval,
Duration publishedRetention) {
public OutboxSettings {
if (relayEnabled == null) {
relayEnabled = true;
}
if (pollInterval == null) {
pollInterval = Duration.ofSeconds(5);
} else if (pollInterval.isZero() || pollInterval.isNegative()) {
throw StartupFailures.envValidation(
"ca-skeleton.outbox.pollInterval must be positive, was " + pollInterval);
}
if (batchSize == null) {
batchSize = 20;
} else if (batchSize <= 0) {
throw StartupFailures.envValidation(
"ca-skeleton.outbox.batchSize must be > 0, was " + batchSize);
}
if (inFlightTimeout == null) {
inFlightTimeout = Duration.ofMinutes(5);
} else if (inFlightTimeout.isZero() || inFlightTimeout.isNegative()) {
throw StartupFailures.envValidation(
"ca-skeleton.outbox.inFlightTimeout must be positive, was " + inFlightTimeout);
}
if (reaperInterval == null) {
reaperInterval = Duration.ofMinutes(10);
} else if (reaperInterval.isZero() || reaperInterval.isNegative()) {
throw StartupFailures.envValidation(
"ca-skeleton.outbox.reaperInterval must be positive, was " + reaperInterval);
}
if (publishedRetention == null) {
publishedRetention = Duration.ofDays(7);
} else if (publishedRetention.isZero() || publishedRetention.isNegative()) {
throw StartupFailures.envValidation(
"ca-skeleton.outbox.publishedRetention must be positive, was " + publishedRetention);
}
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.bootstrap.runtime;
import java.util.Optional;
import org.springframework.core.env.Environment;
/**
* Default {@link SecretSource} ({@link SecretSourceStrategy#ENVIRONMENT}): resolves secrets from
* the Spring {@link Environment}. See README for the design rationale.
*/
public final class EnvironmentSecretSource implements SecretSource {
private final Environment environment;
public EnvironmentSecretSource(Environment environment) {
this.environment = environment;
}
@Override
public Optional<String> resolve(String key) {
String value = environment.getProperty(key);
return (value == null || value.isBlank()) ? Optional.empty() : Optional.of(value);
}
}
@@ -0,0 +1,124 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.Environment;
/**
* Fail-fast startup guard for HikariCP inter-knob constraints. Reads (never re-binds) resolved
* Spring properties as a {@link SmartInitializingSingleton}; an absent property is skipped. See
* README for the design rationale.
*/
public class HikariPoolConstraintValidator implements SmartInitializingSingleton {
static final String CONNECTION_TIMEOUT_KEY = "spring.datasource.hikari.connection-timeout";
static final String VALIDATION_TIMEOUT_KEY = "spring.datasource.hikari.validation-timeout";
static final String KEEPALIVE_TIME_KEY = "spring.datasource.hikari.keepalive-time";
static final String MAX_LIFETIME_KEY = "spring.datasource.hikari.max-lifetime";
static final String LEAK_DETECTION_KEY = "spring.datasource.hikari.leak-detection-threshold";
// Operator-facing env keys, so a boot failure names the APP_* variable the operator set.
// Knobs without a registered env key emit ENV_KEY_PENDING instead of a fabricated name.
static final String CONNECTION_TIMEOUT_ENV_KEY = "APP_DATASOURCE_CONNECTION_TIMEOUT";
static final String MAX_LIFETIME_ENV_KEY = "APP_DATASOURCE_POOL_MAX_LIFETIME";
static final String ENV_KEY_PENDING = "env key pending feature-env-driven-runtime-configuration";
private final Environment environment;
public HikariPoolConstraintValidator(Environment environment) {
this.environment = environment;
}
@Override
public void afterSingletonsInstantiated() {
List<String> violations = new ArrayList<>();
Long connectionTimeout = parseMillis(environment.getProperty(CONNECTION_TIMEOUT_KEY));
Long validationTimeout = parseMillis(environment.getProperty(VALIDATION_TIMEOUT_KEY));
Long keepaliveTime = parseMillis(environment.getProperty(KEEPALIVE_TIME_KEY));
Long maxLifetime = parseMillis(environment.getProperty(MAX_LIFETIME_KEY));
Long leakDetection = parseMillis(environment.getProperty(LEAK_DETECTION_KEY));
if (connectionTimeout != null && connectionTimeout < 250L) {
violations.add(
"D2/HIKARI-CFG-C1: "
+ CONNECTION_TIMEOUT_ENV_KEY
+ " ("
+ CONNECTION_TIMEOUT_KEY
+ ") must be >= 250 ms, was "
+ connectionTimeout);
}
if (validationTimeout != null
&& connectionTimeout != null
&& validationTimeout >= connectionTimeout) {
violations.add(
"D7/HIKARI-CFG-C6: validation-timeout ("
+ VALIDATION_TIMEOUT_KEY
+ ", "
+ ENV_KEY_PENDING
+ ") must be < connection-timeout ("
+ CONNECTION_TIMEOUT_ENV_KEY
+ " / "
+ CONNECTION_TIMEOUT_KEY
+ "); was validation-timeout="
+ validationTimeout
+ ", connection-timeout="
+ connectionTimeout);
}
if (keepaliveTime != null && maxLifetime != null && keepaliveTime >= maxLifetime) {
violations.add(
"D4/HIKARI-CFG-C4: keepalive-time ("
+ KEEPALIVE_TIME_KEY
+ ", "
+ ENV_KEY_PENDING
+ ") must be < max-lifetime ("
+ MAX_LIFETIME_ENV_KEY
+ " / "
+ MAX_LIFETIME_KEY
+ "); was keepalive-time="
+ keepaliveTime
+ ", max-lifetime="
+ maxLifetime);
}
if (leakDetection != null && leakDetection != 0L && leakDetection < 2000L) {
violations.add(
"D5/HIKARI-CFG-C5: leak-detection-threshold ("
+ LEAK_DETECTION_KEY
+ ", "
+ ENV_KEY_PENDING
+ ") must be >= 2000 ms to enable leak detection"
+ " (0 = disabled/allowed); was "
+ leakDetection);
}
if (!violations.isEmpty()) {
throw StartupFailures.envValidation(
"invalid HikariCP configuration (feature-database-connection-pool-contract"
+ " D2/D4/D5/D7): "
+ violations);
}
}
/**
* Parses a raw property string as a plain long (milliseconds). A non-plain-integer value (e.g. a
* Duration string such as {@code "5s"}) yields {@code null}, which the caller treats as absent.
* See README for the design rationale.
*
* @return the parsed milliseconds, or {@code null} when absent/non-numeric
*/
private static Long parseMillis(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return Long.parseLong(raw.trim());
} catch (NumberFormatException e) {
return null; // non-numeric (e.g. Duration string) — treat as absent
}
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.Environment;
/**
* Fail-fast startup guard that enforces OSIV (Open Session In View) being OFF. Reads (never
* re-binds) the resolved property as a {@link SmartInitializingSingleton}; an absent value is left
* to Spring Boot's default and only a present {@code true} is rejected. See README for the design
* rationale.
*/
public class OpenInViewSafetyValidator implements SmartInitializingSingleton {
static final String OPEN_IN_VIEW_KEY = "spring.jpa.open-in-view";
static final String ENV_KEY = "APP_DATASOURCE_OPEN_IN_VIEW";
private final Environment environment;
public OpenInViewSafetyValidator(Environment environment) {
this.environment = environment;
}
@Override
public void afterSingletonsInstantiated() {
Boolean openInView = environment.getProperty(OPEN_IN_VIEW_KEY, Boolean.class);
if (Boolean.TRUE.equals(openInView)) {
throw StartupFailures.envValidation(
ENV_KEY
+ " ("
+ OPEN_IN_VIEW_KEY
+ ") must be false "
+ "(feature-persistence-failure-baseline D2 — OSIV off baseline): "
+ "open-in-view keeps the Hibernate session open through view rendering, "
+ "so a lazy association touched in the presentation layer issues a DB query "
+ "there, violating the layer boundary. Set "
+ ENV_KEY
+ "=false.");
}
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.Environment;
/**
* Fail-fast startup guard for the high-risk numeric runtime knobs (pool/thread sizing). Reads the
* resolved Spring property; an absent key is skipped. See README for the design rationale.
*/
public class RuntimeNumericBoundsValidator implements SmartInitializingSingleton {
/** A numeric property bound: resolved Spring key, the {@code APP_*} env key, and minimum. */
record Bound(String springKey, String envKey, int minInclusive) {}
static final List<Bound> BOUNDS =
List.of(
// positive (>= 1)
new Bound(
"spring.datasource.hikari.maximum-pool-size", "APP_DATASOURCE_POOL_MAX_SIZE", 1),
new Bound("server.tomcat.threads.max", "APP_SERVER_TOMCAT_MAX_THREADS", 1),
new Bound("server.tomcat.max-connections", "APP_SERVER_TOMCAT_MAX_CONNECTIONS", 1),
// non-negative (>= 0)
new Bound("spring.datasource.hikari.minimum-idle", "APP_DATASOURCE_POOL_MIN_IDLE", 0),
new Bound("server.tomcat.threads.min-spare", "APP_SERVER_TOMCAT_MIN_SPARE_THREADS", 0),
new Bound("server.tomcat.accept-count", "APP_SERVER_TOMCAT_ACCEPT_COUNT", 0));
private final Environment environment;
public RuntimeNumericBoundsValidator(Environment environment) {
this.environment = environment;
}
@Override
public void afterSingletonsInstantiated() {
List<String> violations = new ArrayList<>();
for (Bound bound : BOUNDS) {
Integer value = environment.getProperty(bound.springKey(), Integer.class);
if (value == null) {
continue; // absent → skip (framework default owns it)
}
if (value < bound.minInclusive()) {
violations.add(
bound.envKey()
+ " ("
+ bound.springKey()
+ ") must be >= "
+ bound.minInclusive()
+ ", was "
+ value);
}
}
if (!violations.isEmpty()) {
throw StartupFailures.envValidation(
"invalid runtime numeric configuration (feature-env-driven-runtime-configuration "
+ "D10 — no lenient default): "
+ violations);
}
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/**
* Wires the runtime-safety startup fail-fast validators into the running application. See README
* for the design rationale.
*/
@Configuration
public class RuntimeSafetyConfig {
@Bean
StartupSafetyValidator startupSafetyValidator(
Environment environment, RuntimeSafetySettings settings, ListableBeanFactory beanFactory) {
return new StartupSafetyValidator(environment, settings, beanFactory);
}
@Bean
RuntimeNumericBoundsValidator runtimeNumericBoundsValidator(Environment environment) {
return new RuntimeNumericBoundsValidator(environment);
}
@Bean
OpenInViewSafetyValidator openInViewSafetyValidator(Environment environment) {
return new OpenInViewSafetyValidator(environment);
}
@Bean
HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) {
return new HikariPoolConstraintValidator(environment);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.bootstrap.runtime;
import java.util.Optional;
/**
* Backend seam for secret resolution (the concrete backend is chosen by {@link
* SecretSourceFactory}). See README for the design rationale.
*/
public interface SecretSource {
/**
* A blank value MUST be treated as absent so a present-but-empty secret cannot pass a presence
* check.
*
* @return the resolved non-blank value, or empty when absent/blank
*/
Optional<String> resolve(String key);
}
@@ -0,0 +1,26 @@
package dev.caskeleton.bootstrap.runtime;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.ConfigurableEnvironment;
/**
* Wires the secret/config-source backend and its startup fail-fast guard. See README for the design
* rationale.
*/
@Configuration
@EnableConfigurationProperties(SecretSourceSettings.class)
public class SecretSourceConfig {
@Bean
SecretSource secretSource(SecretSourceSettings properties, ConfigurableEnvironment environment) {
return SecretSourceFactory.create(properties.strategy(), environment);
}
@Bean
SecretSourceValidator secretSourceValidator(
ConfigurableEnvironment environment, SecretSource secretSource) {
return new SecretSourceValidator(environment, secretSource);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.bootstrap.runtime;
import org.springframework.core.env.Environment;
/**
* Builds the configured {@link SecretSource} backend; the {@code switch} below is the one extension
* point for adding a backend. See README for the design rationale.
*/
public final class SecretSourceFactory {
private SecretSourceFactory() {}
public static SecretSource create(SecretSourceStrategy strategy, Environment environment) {
return switch (strategy) {
case ENVIRONMENT -> new EnvironmentSecretSource(environment);
};
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.bootstrap.runtime;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Secret backend selection, bound from {@code ca-skeleton.secret-source.strategy}. See README for
* the design rationale.
*
* @param strategy the secret backend; {@code null} → {@code ENVIRONMENT}
*/
@ConfigurationProperties(prefix = "ca-skeleton.secret-source")
public record SecretSourceSettings(SecretSourceStrategy strategy) {
public SecretSourceSettings {
if (strategy == null) {
strategy = SecretSourceStrategy.ENVIRONMENT;
}
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.bootstrap.runtime;
/**
* Selectable secret backend, bound from {@code ca-skeleton.secret-source.strategy}. {@link
* #ENVIRONMENT} is the shipped default. See README for the design rationale.
*/
public enum SecretSourceStrategy {
ENVIRONMENT
// Future backends (each: new SecretSource impl + factory case):
// VAULT, // HashiCorp Vault
// AWS_SECRETS_MANAGER, // AWS Secrets Manager
// GCP_SECRET_MANAGER // GCP Secret Manager
}
@@ -0,0 +1,106 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
/**
* Fail-fast startup guard for the secret/config-source contract (prod-profile only; a violation
* throws so the application refuses to start). See README for the design rationale.
*/
public class SecretSourceValidator implements SmartInitializingSingleton {
/** Prefix marking a dev/local fake credential; forbidden to reach the prod profile. */
static final String LOCAL_DEV_SENTINEL_PREFIX = "__LOCAL_DEV_";
private static final String PROD_PROFILE = "prod";
/**
* Secret keys that must be injected under the {@code prod} profile. {@code
* SecretsClassificationRegistryTest} asserts this list matches the registry 1:1. See README for
* the design rationale.
*/
public static final List<String> REQUIRED_PROD_SECRETS =
List.of(
"APP_DATASOURCE_PASSWORD",
"APP_SECURITY_JWT_SIGNING_KEY",
"APP_SECURITY_OAUTH_CLIENT_SECRET",
"APP_EXTERNAL_API_KEY",
"APP_CACHE_REDIS_PASSWORD",
"APP_PRIVACY_PSEUDONYMIZATION_SALT");
private final ConfigurableEnvironment environment;
private final SecretSource secretSource;
public SecretSourceValidator(ConfigurableEnvironment environment, SecretSource secretSource) {
this.environment = environment;
this.secretSource = secretSource;
}
@Override
public void afterSingletonsInstantiated() {
validateNoLocalDevSentinelInProd();
validateRequiredSecretsPresent();
}
private void validateNoLocalDevSentinelInProd() {
if (!isProdActive()) {
return;
}
List<String> offenders = new ArrayList<>();
for (PropertySource<?> source : environment.getPropertySources()) {
if (source instanceof EnumerablePropertySource<?> enumerable) {
for (String name : enumerable.getPropertyNames()) {
Object value = enumerable.getProperty(name);
if (value instanceof String text
&& text.startsWith(LOCAL_DEV_SENTINEL_PREFIX)
&& !offenders.contains(name)) {
offenders.add(name);
}
}
}
}
if (!offenders.isEmpty()) {
throw StartupFailures.envValidation(
"prod profile forbids '"
+ LOCAL_DEV_SENTINEL_PREFIX
+ "' sentinel credential "
+ "values, but these keys carry one: "
+ offenders
+ " — inject real secrets from the secret manager / mounted env for prod.");
}
}
private void validateRequiredSecretsPresent() {
if (!isProdActive()) {
return;
}
List<String> missing = new ArrayList<>();
for (String key : REQUIRED_PROD_SECRETS) {
if (secretSource.resolve(key).isEmpty()) {
missing.add(key);
}
}
if (!missing.isEmpty()) {
throw StartupFailures.envValidation(
"prod profile requires these secrets to be injected, but they are missing/blank: "
+ missing
+ " — booting with an empty secret is forbidden "
+ "(feature-secrets-config-source-contract §테스트 계약).");
}
}
private boolean isProdActive() {
// Case-insensitive: a typo such as SPRING_PROFILES_ACTIVE=PROD must still match.
for (String profile : environment.getActiveProfiles()) {
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.Environment;
/**
* Fail-fast startup guard for env-combination invariants. Implemented as a {@link
* SmartInitializingSingleton} so the check runs once after every singleton is instantiated but
* before the context finishes refreshing; a violation throws so the context refuses to start. See
* README for the design rationale.
*/
public class StartupSafetyValidator implements SmartInitializingSingleton {
/**
* Bean names every instance-coordination capability must register when {@code
* APP_MULTI_INSTANCE_ENABLED=true}; this validator only asserts their presence.
*/
static final List<String> REQUIRED_MULTI_INSTANCE_BEANS =
List.of(
"distributedLockProvider", // distributed lock (JdbcLockRegistry)
"cacheStampedeProtection", // cache stampede protection (Redisson RLock)
"outboxLeaderElection", // outbox leader election (SKIP LOCKED)
"distributedRateLimiter", // distributed rate limiter (Redis counter)
"migrationStartupRunner" // platform migration startup job
);
private static final String PROD_PROFILE = "prod";
private final Environment environment;
private final RuntimeSafetySettings settings;
private final ListableBeanFactory beanFactory;
public StartupSafetyValidator(
Environment environment, RuntimeSafetySettings settings, ListableBeanFactory beanFactory) {
this.environment = environment;
this.settings = settings;
this.beanFactory = beanFactory;
}
@Override
public void afterSingletonsInstantiated() {
validateProdSafety();
validateMultiInstance();
}
/** Under the {@code prod} profile, internal-detail and body-capture toggles must be off. */
private void validateProdSafety() {
if (!isProdActive()) {
return;
}
List<String> unsafe = new ArrayList<>();
if (settings.errorDetailExposureEnabled()) {
unsafe.add("APP_ERROR_DETAIL_EXPOSURE_ENABLED");
}
if (settings.logBodyCaptureEnabled()) {
unsafe.add("APP_LOG_BODY_CAPTURE_ENABLED");
}
if (!unsafe.isEmpty()) {
// prod-unsafe toggle left on under prod → PROFILE_MISMATCH (exit 71).
throw StartupFailures.profileMismatch(
"prod profile forbids these toggles being enabled: "
+ unsafe
+ " — set them to false for the prod profile.");
}
}
/** When multi-instance is on, every instance-coordination capability bean must exist. */
private void validateMultiInstance() {
if (!settings.multiInstanceEnabled()) {
return;
}
List<String> missing = new ArrayList<>();
for (String beanName : REQUIRED_MULTI_INSTANCE_BEANS) {
if (!beanFactory.containsBean(beanName)) {
missing.add(beanName);
}
}
if (!missing.isEmpty()) {
// required coordination bean absent under multi-instance → REQUIRED_ADAPTER_DISABLED (exit
// 72).
throw StartupFailures.requiredAdapterDisabled(
"APP_MULTI_INSTANCE_ENABLED=true requires the instance-coordination beans "
+ REQUIRED_MULTI_INSTANCE_BEANS
+ ", but these are missing: "
+ missing);
}
}
private boolean isProdActive() {
// Case-insensitive: a typo such as SPRING_PROFILES_ACTIVE=PROD must still match.
for (String profile : environment.getActiveProfiles()) {
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.bootstrap.runtime.startup;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.Environment;
/**
* Keeps the Flyway prod safety nets on; raises PROFILE_MISMATCH (exit 71) when an override
* re-enables a forbidden option. See README for the design rationale.
*/
public class FlywayProdSafetyValidator implements SmartInitializingSingleton {
static final String BASELINE_ON_MIGRATE_KEY = "spring.flyway.baseline-on-migrate";
static final String OUT_OF_ORDER_KEY = "spring.flyway.out-of-order";
static final String CLEAN_DISABLED_KEY = "spring.flyway.clean-disabled";
private static final String PROD_PROFILE = "prod";
private final Environment environment;
public FlywayProdSafetyValidator(Environment environment) {
this.environment = environment;
}
@Override
public void afterSingletonsInstantiated() {
if (!isProdActive()) {
return;
}
List<String> violations = new ArrayList<>();
if (isTrue(BASELINE_ON_MIGRATE_KEY)) {
violations.add(BASELINE_ON_MIGRATE_KEY + "=true (removes the missing-migration safety net)");
}
if (isTrue(OUT_OF_ORDER_KEY)) {
violations.add(OUT_OF_ORDER_KEY + "=true (breaks migration ordering consistency)");
}
if (isFalse(CLEAN_DISABLED_KEY)) {
violations.add(CLEAN_DISABLED_KEY + "=false (re-arms destructive Flyway clean)");
}
if (!violations.isEmpty()) {
throw StartupFailures.profileMismatch(
"prod profile forbids these Flyway options (feature-migration-startup-contract"
+ " D2/D4): "
+ violations
+ " — partial-schema recovery goes through runbook://migration/manual-recovery,"
+ " never an in-prod Flyway repair.");
}
}
private boolean isTrue(String key) {
return Boolean.parseBoolean(environment.getProperty(key));
}
private boolean isFalse(String key) {
String value = environment.getProperty(key);
return value != null && "false".equalsIgnoreCase(value.trim());
}
private boolean isProdActive() {
// Case-insensitive so a SPRING_PROFILES_ACTIVE=PROD typo still triggers the guard.
for (String profile : environment.getActiveProfiles()) {
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.bootstrap.runtime.startup;
/**
* A Flyway forward-only migration failed during startup (MIGRATION_FAILED, exit 70 — sysexits
* {@code EX_SOFTWARE}). Phase: migration. Wraps the underlying {@code FlywayException} as the cause
* to preserve the root error for triage.
*/
public class MigrationFailedException extends StartupFailureException {
private static final long serialVersionUID = 1L;
public MigrationFailedException(String message, Throwable cause) {
super(StartupErrorCode.MIGRATION_FAILED, message, cause);
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.bootstrap.runtime.startup;
import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings;
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/**
* Composition-root wiring for the startup migration guards and the Flyway migration strategy. See
* README for the design rationale.
*/
@Configuration
public class MigrationStartupConfig {
/**
* Bean name must stay {@code migrationStartupRunner} (Flyway autoconfiguration delegates to it).
*/
@Bean
FlywayMigrationStrategy migrationStartupRunner(RuntimeSafetySettings settings) {
return new MigrationStartupRunner(settings);
}
/** Env-validation (exit 78): datasource connection env present before migration runs. */
@Bean
RequiredEnvironmentValidator requiredEnvironmentValidator(Environment environment) {
return new RequiredEnvironmentValidator(environment);
}
/** Profile check (exit 71): forbidden Flyway options stay off under the prod profile. */
@Bean
FlywayProdSafetyValidator flywayProdSafetyValidator(Environment environment) {
return new FlywayProdSafetyValidator(environment);
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.bootstrap.runtime.startup;
import static net.logstash.logback.argument.StructuredArguments.kv;
import dev.caskeleton.bootstrap.settings.RuntimeSafetySettings;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.output.MigrateResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.flyway.autoconfigure.FlywayMigrationStrategy;
/**
* Runs the Flyway migration during context refresh so it completes before readiness, and translates
* a {@link FlywayException} into a {@link MigrationFailedException} (exit 70). See README for the
* design rationale.
*/
public class MigrationStartupRunner implements FlywayMigrationStrategy {
private static final Logger log =
LoggerFactory.getLogger("dev.caskeleton.bootstrap.runtime.startup");
private final RuntimeSafetySettings settings;
public MigrationStartupRunner(RuntimeSafetySettings settings) {
this.settings = settings;
}
@Override
public void migrate(Flyway flyway) {
if (!settings.migrationOnStartup()) {
log.info(
"startup phase {}: in-app Flyway migration is disabled via configuration (ca-skeleton.runtime.migration-on-startup=false)",
kv("startup.phase", StartupPhase.MIGRATION.wireName()));
return;
}
log.info(
"startup phase {}: applying Flyway forward-only migrations",
kv("startup.phase", StartupPhase.MIGRATION.wireName()));
try {
MigrateResult result = flyway.migrate();
int executed = (result != null) ? result.migrationsExecuted : 0;
log.info(
"startup phase {}: migration complete, {} migration(s) applied",
kv("startup.phase", StartupPhase.MIGRATION.wireName()),
executed);
} catch (FlywayException e) {
// StartupFailures emits the structured failure log before constructing the exception.
throw StartupFailures.migrationFailed(
"Flyway forward-only migration failed during startup", e);
}
}
}
@@ -0,0 +1,15 @@
package dev.caskeleton.bootstrap.runtime.startup;
/**
* An active profile conflicts with an enabled prod-forbidden setting — e.g. a prod-unsafe toggle or
* a forbidden Flyway option left on under the {@code prod} profile (PROFILE_MISMATCH, exit 71 —
* ca-tmpl internal convention, see {@link StartupErrorCode}). Phase: profile-check.
*/
public class ProfileMismatchException extends StartupFailureException {
private static final long serialVersionUID = 1L;
public ProfileMismatchException(String message) {
super(StartupErrorCode.PROFILE_MISMATCH, message, null);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.bootstrap.runtime.startup;
/**
* A required capability adapter / coordination bean is disabled or absent at startup
* (REQUIRED_ADAPTER_DISABLED, exit 72 — ca-tmpl internal convention, see {@link StartupErrorCode}).
* Phase: adapter-enablement. Distinct from the runtime-lifecycle {@code ADAPTER_DISABLED}: this is
* a startup validation, not a runtime invoke against a disabled optional adapter. See README for
* the design rationale.
*/
public class RequiredAdapterDisabledException extends StartupFailureException {
private static final long serialVersionUID = 1L;
public RequiredAdapterDisabledException(String message) {
super(StartupErrorCode.REQUIRED_ADAPTER_DISABLED, message, null);
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.bootstrap.runtime.startup;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.Environment;
/**
* Raises STARTUP_VALIDATION_FAILED (exit 78) naming every missing operator env key before a
* migration fails with an opaque driver error. Absent or blank counts as missing. See README for
* the design rationale.
*/
public class RequiredEnvironmentValidator implements SmartInitializingSingleton {
/** Spring property key → operator-facing env key (named in the failure message). */
static final Map<String, String> REQUIRED_DATASOURCE_ENV =
Map.of(
"spring.datasource.url", "APP_DATASOURCE_URL",
"spring.datasource.username", "APP_DATASOURCE_USERNAME",
"spring.datasource.driver-class-name", "APP_DATASOURCE_DRIVER");
private final Environment environment;
public RequiredEnvironmentValidator(Environment environment) {
this.environment = environment;
}
@Override
public void afterSingletonsInstantiated() {
List<String> missing = new ArrayList<>();
// Sorted for a stable, deterministic message regardless of map iteration order.
REQUIRED_DATASOURCE_ENV.entrySet().stream()
.sorted(Map.Entry.comparingByValue())
.forEach(
entry -> {
String value = environment.getProperty(entry.getKey());
if (value == null || value.isBlank()) {
missing.add(entry.getValue() + " (" + entry.getKey() + ")");
}
});
if (!missing.isEmpty()) {
throw StartupFailures.envValidation(
"required datasource environment is missing or blank — a Flyway migration"
+ " cannot run without it: "
+ missing);
}
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.bootstrap.runtime.startup;
/**
* The four startup-failure causes. Each exit code becomes the JVM exit status only because the
* carrying exception implements {@link org.springframework.boot.ExitCodeGenerator} (see {@link
* StartupFailureException}). See README for the design rationale.
*/
public enum StartupErrorCode {
STARTUP_VALIDATION_FAILED(78, StartupPhase.ENV_VALIDATION),
MIGRATION_FAILED(70, StartupPhase.MIGRATION),
PROFILE_MISMATCH(71, StartupPhase.PROFILE_CHECK),
REQUIRED_ADAPTER_DISABLED(72, StartupPhase.ADAPTER_ENABLEMENT);
/** Registry-fixed category for all four codes. */
private static final String CATEGORY = "INTERNAL";
private final int exitCode;
private final StartupPhase phase;
StartupErrorCode(int exitCode, StartupPhase phase) {
this.exitCode = exitCode;
this.phase = phase;
}
public int exitCode() {
return exitCode;
}
/** The startup phase this cause belongs to ({@code startup.phase} log field). */
public StartupPhase phase() {
return phase;
}
/** Registry category — always {@code INTERNAL} ({@code error.category} log field). */
public String category() {
return CATEGORY;
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.bootstrap.runtime.startup;
import org.springframework.boot.ExitCodeGenerator;
/**
* Base type for the four startup-failure causes. Implementing {@link ExitCodeGenerator} turns the
* cause's {@link StartupErrorCode#exitCode()} into the JVM exit status when context refresh fails.
* Extends {@link IllegalStateException} for source/behaviour compatibility with the prior {@code
* IllegalStateException}-based startup validation. See README for the design rationale.
*/
public abstract class StartupFailureException extends IllegalStateException
implements ExitCodeGenerator {
private static final long serialVersionUID = 1L;
private final transient StartupErrorCode errorCode;
protected StartupFailureException(StartupErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public StartupErrorCode errorCode() {
return errorCode;
}
@Override
public int getExitCode() {
return errorCode.exitCode();
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.bootstrap.runtime.startup;
import org.springframework.boot.SpringBootExceptionReporter;
/**
* Marks typed startup failures as already reported by {@link StartupFailures}, suppressing Spring
* Boot's generic "Application run failed" stacktrace for those fatal paths only.
*/
public class StartupFailureExceptionReporter implements SpringBootExceptionReporter {
@Override
public boolean reportException(Throwable failure) {
return containsStartupFailure(failure);
}
private static boolean containsStartupFailure(Throwable failure) {
for (Throwable current = failure; current != null; current = current.getCause()) {
if (current instanceof StartupFailureException) {
return true;
}
if (current.getCause() == current) {
break;
}
}
return false;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.bootstrap.runtime.startup;
import java.util.concurrent.atomic.AtomicBoolean;
/** Process-local marker that the canonical startup failure log has already been emitted. */
public final class StartupFailureLogState {
private static final AtomicBoolean STARTUP_FAILURE_REPORTED = new AtomicBoolean(false);
private StartupFailureLogState() {}
public static void markStartupFailureReported() {
STARTUP_FAILURE_REPORTED.set(true);
}
public static boolean startupFailureReported() {
return STARTUP_FAILURE_REPORTED.get();
}
public static void clearForTest() {
STARTUP_FAILURE_REPORTED.set(false);
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.bootstrap.runtime.startup;
import static net.logstash.logback.argument.StructuredArguments.kv;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Single source for raising a startup failure. Every factory method emits the structured failure
* log (with the {@code startup.phase} / {@code error.code} / {@code error.category} fields) before
* returning the exception to throw. See README for the design rationale.
*/
public final class StartupFailures {
private static final Logger log =
LoggerFactory.getLogger("dev.caskeleton.bootstrap.runtime.startup");
private StartupFailures() {}
public static StartupValidationException envValidation(String detail) {
emit(StartupErrorCode.STARTUP_VALIDATION_FAILED, detail, null);
return new StartupValidationException(detail);
}
public static ProfileMismatchException profileMismatch(String detail) {
emit(StartupErrorCode.PROFILE_MISMATCH, detail, null);
return new ProfileMismatchException(detail);
}
public static RequiredAdapterDisabledException requiredAdapterDisabled(String detail) {
emit(StartupErrorCode.REQUIRED_ADAPTER_DISABLED, detail, null);
return new RequiredAdapterDisabledException(detail);
}
public static MigrationFailedException migrationFailed(String detail, Throwable cause) {
emit(StartupErrorCode.MIGRATION_FAILED, detail, cause);
return new MigrationFailedException(detail, cause);
}
/** Emits the structured startup-failure log. {@code cause} may be {@code null}. */
static void emit(StartupErrorCode code, String detail, Throwable cause) {
StartupFailureLogState.markStartupFailureReported();
if (cause == null) {
log.error(
"startup failure in phase {}: {}",
kv("startup.phase", code.phase().wireName()),
detail,
kv("error.code", code.name()),
kv("error.category", code.category()));
} else {
Throwable rootCause = rootCause(cause);
log.error(
"startup failure in phase {}: {} (root cause {}: {})",
kv("startup.phase", code.phase().wireName()),
detail,
kv("error.root_cause.class", rootCause.getClass().getName()),
kv("error.root_cause.message", safeMessage(rootCause)),
kv("error.code", code.name()),
kv("error.category", code.category()));
}
}
private static Throwable rootCause(Throwable cause) {
Throwable current = cause;
while (current.getCause() != null && current.getCause() != current) {
current = current.getCause();
}
return current;
}
private static String safeMessage(Throwable cause) {
String message = cause.getMessage();
return message == null ? "" : message;
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.bootstrap.runtime.startup;
/**
* The startup lifecycle phase a fail-fast guard belongs to, written to the {@code startup.phase}
* structured-log field so an operator can tell the four failure causes apart in the logs. See
* README for the design rationale.
*/
public enum StartupPhase {
/** Required environment / configuration value missing or malformed (exit 78). */
ENV_VALIDATION("env-validation"),
/** Flyway forward-only migration failed (exit 70). */
MIGRATION("migration"),
/** A required capability adapter/bean was disabled or absent (exit 72). */
ADAPTER_ENABLEMENT("adapter-enablement"),
/** An active profile conflicts with an enabled prod-forbidden setting (exit 71). */
PROFILE_CHECK("profile-check");
private final String wireName;
StartupPhase(String wireName) {
this.wireName = wireName;
}
/** The verbatim value written to the {@code startup.phase} structured-log field. */
public String wireName() {
return wireName;
}
}
@@ -0,0 +1,14 @@
package dev.caskeleton.bootstrap.runtime.startup;
/**
* A required environment / configuration value is missing or malformed (STARTUP_VALIDATION_FAILED,
* exit 78 — sysexits {@code EX_CONFIG}). Phase: env-validation.
*/
public class StartupValidationException extends StartupFailureException {
private static final long serialVersionUID = 1L;
public StartupValidationException(String message) {
super(StartupErrorCode.STARTUP_VALIDATION_FAILED, message, null);
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.bootstrap.settings;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Bootstrap-scope settings bound from {@code ca-skeleton.bootstrap.*}. {@code appName} is fail-fast
* (@NotBlank): no sensible default, so a blank value must stop startup rather than warn-and-default
* like the other settings. See README for the design rationale.
*/
@Validated
@ConfigurationProperties(prefix = "ca-skeleton.bootstrap")
public record BootstrapSettings(
@NotBlank(
message = "APP_NAME (ca-skeleton.bootstrap.app-name) is required and must not be blank")
String appName) {}
@@ -0,0 +1,87 @@
package dev.caskeleton.bootstrap.settings;
import java.time.ZoneId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Logging settings bound from {@code ca-skeleton.logging.*}. Every knob is "warn-and-default": a
* bad value logs a warning and proceeds with a safe fallback rather than failing startup. See
* README for the design rationale.
*/
@ConfigurationProperties(prefix = "ca-skeleton.logging")
public record LoggingSettings(File file, Async async, Json json) {
public record File(
boolean enabled, String path, String maxSize, int maxHistory, String totalSizeCap) {
private static final Logger log = LoggerFactory.getLogger(File.class);
public File {
if (enabled) {
if (path == null || path.isBlank()) {
log.warn(
"APP_LOG_FILE_PATH is blank while APP_LOG_FILE_ENABLED=true; using 'logs/app.json'");
path = "logs/app.json";
}
if (maxHistory <= 0) {
log.warn("APP_LOG_FILE_MAX_HISTORY must be >= 1 (got {}); using 14", maxHistory);
maxHistory = 14;
}
// Size strings (maxSize, totalSizeCap) are parsed by logback; let it own that contract.
}
}
}
public record Async(boolean enabled, int queueSize, int discardingThreshold) {
private static final Logger log = LoggerFactory.getLogger(Async.class);
public Async {
if (enabled) {
if (queueSize <= 0) {
log.warn("APP_LOG_ASYNC_QUEUE_SIZE must be >= 1 (got {}); using 512", queueSize);
queueSize = 512;
}
if (discardingThreshold < 0) {
log.warn(
"APP_LOG_ASYNC_DISCARDING_THRESHOLD must be >= 0 (got {}); using 20",
discardingThreshold);
discardingThreshold = 20;
}
}
}
}
public record Json(
String timezone, String timestampPattern, boolean includeCallerData, int loggerNameLength) {
private static final Logger log = LoggerFactory.getLogger(Json.class);
private static final String DEFAULT_TIMEZONE = "UTC";
private static final String DEFAULT_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX";
public Json {
if (timezone == null || timezone.isBlank()) {
log.warn("APP_LOG_JSON_TIMEZONE is blank; using '{}'", DEFAULT_TIMEZONE);
timezone = DEFAULT_TIMEZONE;
} else if (!"default".equalsIgnoreCase(timezone)) {
try {
ZoneId.of(timezone);
} catch (Exception e) {
log.warn(
"APP_LOG_JSON_TIMEZONE '{}' is not a valid IANA zone; using '{}'",
timezone,
DEFAULT_TIMEZONE);
timezone = DEFAULT_TIMEZONE;
}
}
if (timestampPattern == null || timestampPattern.isBlank()) {
log.warn("APP_LOG_JSON_TIMESTAMP_PATTERN is blank; using ISO 8601 default");
timestampPattern = DEFAULT_PATTERN;
}
if (loggerNameLength < 0) {
log.warn(
"APP_LOG_JSON_LOGGER_NAME_LENGTH must be >= 0 (got {}); using 0", loggerNameLength);
loggerNameLength = 0;
}
}
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.bootstrap.settings;
import java.nio.charset.StandardCharsets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Privacy settings bound from {@code ca-skeleton.privacy.*}. Holds the HMAC salt used to
* pseudonymize {@code user_principal} in security/audit logs. A blank salt is "warn-and-default":
* it logs a warning and falls back to a dev sentinel so local/test runs never fail to start. See
* README for the design rationale.
*/
@ConfigurationProperties(prefix = "ca-skeleton.privacy")
public record PrivacySettings(String pseudonymizationSalt) {
private static final Logger log = LoggerFactory.getLogger(PrivacySettings.class);
/** Dev/local fallback salt — the {@code __LOCAL_DEV_} prefix is the prod-unsafe sentinel. */
static final String DEV_SENTINEL_SALT = "__LOCAL_DEV_pseudonymization_salt";
public PrivacySettings {
if (pseudonymizationSalt == null || pseudonymizationSalt.isBlank()) {
log.warn(
"APP_PRIVACY_PSEUDONYMIZATION_SALT is blank; using a dev sentinel salt. "
+ "Set a real secret-manager value before production.");
pseudonymizationSalt = DEV_SENTINEL_SALT;
}
}
public byte[] saltBytes() {
return pseudonymizationSalt.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,16 @@
package dev.caskeleton.bootstrap.settings;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/**
* Operational safety toggles bound from {@code ca-skeleton.runtime.*} (all default {@code false}
* except migrationOnStartup which defaults to {@code true}), enforced at startup by {@code
* StartupSafetyValidator}. See README for the design rationale.
*/
@ConfigurationProperties(prefix = "ca-skeleton.runtime")
public record RuntimeSafetySettings(
boolean errorDetailExposureEnabled,
boolean logBodyCaptureEnabled,
boolean multiInstanceEnabled,
@DefaultValue("true") boolean migrationOnStartup) {}
@@ -0,0 +1,30 @@
package dev.caskeleton.bootstrap.tracing;
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
/** Micrometer/OTel-backed {@link SpanErrorRecorder}. See README for the design rationale. */
public final class MicrometerSpanErrorRecorder implements SpanErrorRecorder {
private final Tracer tracer;
public MicrometerSpanErrorRecorder(Tracer tracer) {
this.tracer = tracer;
}
@Override
public void recordException(Throwable error, String errorCode) {
if (error == null) {
return;
}
Span span = tracer.currentSpan();
if (span == null) {
return;
}
span.error(error);
if (errorCode != null) {
span.tag("error.code", errorCode);
}
}
}
@@ -0,0 +1,96 @@
package dev.caskeleton.bootstrap.tracing;
import dev.caskeleton.shared.tracing.SpanErrorRecorder;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.otel.bridge.OtelBaggageManager;
import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
import io.micrometer.tracing.otel.bridge.OtelTracer;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.samplers.Sampler;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
/** Composition-root wiring for distributed tracing. See README for the design rationale. */
@Configuration
@EnableConfigurationProperties(TracingSettings.class)
public class TracingConfig {
private final TracingSettings properties;
private final Environment environment;
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
// ObjectProvider<MeterRegistry> so wiring works even when no MeterRegistry bean exists.
public TracingConfig(
TracingSettings properties,
Environment environment,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
this.properties = properties;
this.environment = environment;
this.meterRegistryProvider = meterRegistryProvider;
}
@Bean
public TracingSamplingRateGaugeRegistrar tracingSamplingRateGaugeRegistrar() {
String activeProfile = resolveActiveProfile();
double effectiveRate =
new TracingSampleRateResolver().resolve(activeProfile, properties.sampleRate());
TracingSamplingRateGauge.register(activeProfile, effectiveRate, meterRegistryProvider);
return new TracingSamplingRateGaugeRegistrar(activeProfile, effectiveRate);
}
private String resolveActiveProfile() {
String[] profiles = environment.getActiveProfiles();
return (profiles != null && profiles.length > 0) ? profiles[0] : "local";
}
@Bean
@ConditionalOnMissingBean(Tracer.class)
@ConditionalOnProperty(
prefix = "ca-skeleton.tracing",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
Tracer micrometerTracer() {
double sampleRate =
new TracingSampleRateResolver().resolve(resolveActiveProfile(), properties.sampleRate());
SdkTracerProvider tracerProvider =
SdkTracerProvider.builder()
.setSampler(Sampler.parentBased(Sampler.traceIdRatioBased(sampleRate)))
.build();
OpenTelemetrySdk openTelemetry =
OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build();
OtelCurrentTraceContext currentTraceContext = new OtelCurrentTraceContext();
return new OtelTracer(
openTelemetry.getTracer("dev.caskeleton"),
currentTraceContext,
event -> {},
new OtelBaggageManager(currentTraceContext, List.of(), List.of()));
}
/**
* {@link ObjectProvider}, not {@code @ConditionalOnBean(Tracer.class)}: a missing {@link Tracer}
* yields {@link SpanErrorRecorder#NOOP} rather than no bean, so downstream injection points never
* break when tracing is off.
*/
@Bean
@ConditionalOnMissingBean(SpanErrorRecorder.class)
SpanErrorRecorder micrometerSpanErrorRecorder(ObjectProvider<Tracer> tracerProvider) {
Tracer tracer = tracerProvider.getIfAvailable();
if (tracer == null) {
return SpanErrorRecorder.NOOP;
}
return new MicrometerSpanErrorRecorder(tracer);
}
/** Exists so tests can inspect what was registered without a live MeterRegistry. */
public record TracingSamplingRateGaugeRegistrar(
String activeProfile, double effectiveSampleRate) {}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.bootstrap.tracing;
import java.util.Locale;
/**
* Resolves the effective tracing sample rate from the active profile and the configured override.
* Plain Java, no Spring dependency, so it is reusable from both the {@code
* EnvironmentPostProcessor} and bean phases. See README for the design rationale.
*/
public final class TracingSampleRateResolver {
/**
* @param profile Spring active profile name; {@code null}/blank treated as "anything else"
*/
public static double defaultRateForProfile(String profile) {
if (profile == null) {
return 1.0;
}
return switch (profile.trim().toLowerCase(Locale.ROOT)) {
case "prod" -> 0.01;
case "staging" -> 0.10;
case "dev" -> 1.0;
case "local" -> 1.0;
default -> 1.0;
};
}
/**
* A configured override wins over the per-profile default.
*
* @param configuredRate raw string from {@code APP_TRACING_SAMPLE_RATE}; may be blank
* @return effective sample rate in [0.0, 1.0]
*/
public double resolve(String profile, String configuredRate) {
if (configuredRate != null && !configuredRate.isBlank()) {
try {
double v = Double.parseDouble(configuredRate.trim());
if (v >= 0.0 && v <= 1.0) {
return v;
}
} catch (NumberFormatException ignored) {
// fall through to profile default
}
}
return defaultRateForProfile(profile);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.bootstrap.tracing;
import java.util.Map;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
/**
* Bridges the resolved tracing sample rate into Spring Boot's native {@code
* management.tracing.sampling.probability}. Registered via {@code META-INF/spring.factories}. See
* README for the design rationale.
*/
public class TracingSamplingEnvironmentPostProcessor implements EnvironmentPostProcessor {
private static final String NATIVE_KEY = "management.tracing.sampling.probability";
private static final String CA_SAMPLE_RATE_KEY = "ca-skeleton.tracing.sample-rate";
private final TracingSampleRateResolver resolver = new TracingSampleRateResolver();
@Override
public void postProcessEnvironment(
ConfigurableEnvironment environment, SpringApplication application) {
// A user-supplied value takes precedence.
if (environment.containsProperty(NATIVE_KEY)) {
return;
}
String[] profiles = environment.getActiveProfiles();
String profile = (profiles != null && profiles.length > 0) ? profiles[0] : "local";
String configured = environment.getProperty(CA_SAMPLE_RATE_KEY);
double rate = resolver.resolve(profile, configured);
environment
.getPropertySources()
.addLast(
new MapPropertySource(
"tracingSamplingBridge", Map.of(NATIVE_KEY, String.valueOf(rate))));
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.bootstrap.tracing;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
/**
* Registers the {@code tracing.sampling.rate} gauge. Resolves the registry via an {@link
* ObjectProvider} so it stays a no-op when no {@code MeterRegistry} bean is present. See README for
* the design rationale.
*/
public final class TracingSamplingRateGauge {
private static final Logger log = LoggerFactory.getLogger(TracingSamplingRateGauge.class);
public static final String METRIC_NAME = "tracing.sampling.rate";
public static final String TAG_PROFILE = "profile";
private TracingSamplingRateGauge() {}
public static void register(
String activeProfile,
double effectiveSampleRate,
ObjectProvider<MeterRegistry> meterRegistryProvider) {
MeterRegistry registry = meterRegistryProvider.getIfAvailable();
if (registry == null) {
return; // no-op: no Actuator / MeterRegistry on classpath
}
try {
Gauge.builder(METRIC_NAME, () -> effectiveSampleRate)
.tag(TAG_PROFILE, activeProfile != null ? activeProfile : "unknown")
.description("Effective distributed-tracing sample rate for the active profile")
.register(registry);
} catch (RuntimeException ex) {
log.warn("tracing metrics: failed to register gauge {}", METRIC_NAME, ex);
}
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.bootstrap.tracing;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.net.URI;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Tracing settings bound from {@code ca-skeleton.tracing.*}. A blank {@code sampleRate} means
* "defer to the per-profile default". See README for the design rationale.
*/
@ConfigurationProperties(prefix = "ca-skeleton.tracing")
@Validated
public record TracingSettings(boolean enabled, String sampleRate, Exporter exporter) {
/** Nested properties for the OTLP exporter endpoint. */
public record Exporter(String otlpEndpoint) {
public Exporter {
if (otlpEndpoint == null) {
otlpEndpoint = "";
}
}
public boolean isConfigured() {
return otlpEndpoint != null && !otlpEndpoint.isBlank();
}
}
public TracingSettings {
if (sampleRate == null) {
sampleRate = "";
}
if (exporter == null) {
exporter = new Exporter("");
}
// Blank sampleRate = defer to the per-profile default; skip numeric validation.
if (!sampleRate.isBlank()) {
validateSampleRate(sampleRate);
}
validateOtlpEndpoint(exporter.otlpEndpoint());
}
/**
* Precondition: {@link #sampleRate()} must be non-blank — blank means "defer to the per-profile
* default", so callers must check {@code sampleRate().isBlank()} first (throws {@link
* IllegalStateException} otherwise).
*/
public double sampleRateValue() {
if (sampleRate.isBlank()) {
throw new IllegalStateException(
"sampleRateValue() called on a blank sampleRate — blank means "
+ "\"defer to TracingSampleRateResolver per-profile default\". "
+ "Check sampleRate().isBlank() before calling this method.");
}
return Double.parseDouble(sampleRate.trim());
}
private static void validateSampleRate(String raw) {
try {
double v = Double.parseDouble(raw.trim());
if (v < 0.0 || v > 1.0) {
throw StartupFailures.envValidation(
"APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0] "
+ "(feature-distributed-tracing-contract D6 float_between_0_and_1); "
+ "got: "
+ raw);
}
} catch (NumberFormatException ex) {
throw StartupFailures.envValidation(
"APP_TRACING_SAMPLE_RATE must be a float in [0.0, 1.0] "
+ "(feature-distributed-tracing-contract D6 float_between_0_and_1); "
+ "got: "
+ raw);
}
}
private static void validateOtlpEndpoint(String endpoint) {
if (endpoint == null || endpoint.isBlank()) {
return; // empty = exporter off
}
try {
URI uri = URI.create(endpoint.trim());
String scheme = uri.getScheme();
if (scheme == null || scheme.isBlank()) {
throw StartupFailures.envValidation(
"OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL with a scheme "
+ "(feature-distributed-tracing-contract D1 url_or_empty); "
+ "got: "
+ endpoint);
}
} catch (IllegalArgumentException ex) {
throw StartupFailures.envValidation(
"OTEL_EXPORTER_OTLP_ENDPOINT must be a valid URL or empty "
+ "(feature-distributed-tracing-contract D1 url_or_empty); "
+ "got: "
+ endpoint);
}
}
}
@@ -0,0 +1,5 @@
org.springframework.boot.EnvironmentPostProcessor=\
dev.caskeleton.bootstrap.tracing.TracingSamplingEnvironmentPostProcessor
org.springframework.boot.SpringBootExceptionReporter=\
dev.caskeleton.bootstrap.runtime.startup.StartupFailureExceptionReporter
@@ -0,0 +1,529 @@
# =============================================================================
# Mirrors src/.env into Spring's Environment. Comments document allowed values;
# input validation lives in the *Settings records under each module.
# =============================================================================
spring:
application:
# free-form string (also exposed as the JSON log "app" field)
name: ${APP_NAME}
mvc:
problemdetails:
# Architectural decision D1/D5: RFC 7807 ProblemDetail is rejected in favour
# of the custom {success,data,error,meta} envelope. Pin OFF explicitly so a
# future Spring Boot default flip cannot silently re-enable it.
enabled: false
web:
error:
# always | never | on_param
include-stacktrace: ${APP_SERVER_ERROR_INCLUDE_STACKTRACE}
# always | never | on_param
include-message: ${APP_SERVER_ERROR_INCLUDE_MESSAGE}
profiles:
# common values: local | dev | stage | prod (free-form)
active: ${SPRING_PROFILES_ACTIVE:local}
datasource:
# jdbc URL: jdbc:postgresql://host:5432/db
url: ${APP_DATASOURCE_URL}
username: ${APP_DATASOURCE_USERNAME}
password: ${APP_DATASOURCE_PASSWORD}
driver-class-name: ${APP_DATASOURCE_DRIVER}
hikari:
# D1 (feature-database-connection-pool-contract): small-pool axiom + PostgreSQL formula
# starting point (maximumPoolSize = cores * 2 + effective_spindle_count, adjust via load
# test). Fixed-size pool recommended (minimumIdle = maximumPoolSize). Must satisfy
# application-port D12 REQUIRES_NEW lower bound:
# maxPoolSize >= concurrent_threads * (1 + max_inNew_depth) + 1
# Value owner: feature-env-driven-runtime-configuration (APP_DATASOURCE_POOL_MAX_SIZE).
# integer >= 1
maximum-pool-size: ${APP_DATASOURCE_POOL_MAX_SIZE}
# D1: fixed-size pool recommended (minimumIdle = maximumPoolSize per HikariCP #HIKARI-CFG-C8).
# Current registry value min-idle=2 is a MIN_IDLE_POLICY_DRIFT vs the fixed-size
# recommendation; value alignment is delegated to feature-env-driven-runtime-configuration.
# integer >= 0
minimum-idle: ${APP_DATASOURCE_POOL_MIN_IDLE}
# D2 (HIKARI-CFG-C1): fail-fast pin — reject pool-starved threads quickly rather than
# holding them for 30 s (HikariCP default). Must be >= 250 ms (enforced at startup by
# HikariPoolConstraintValidator). Typical synchronous HTTP path value: a few seconds.
# CONNECTION_TIMEOUT_FORMAT_DRIFT: env-keys.yaml default is "5s" (Duration string) while
# src/.env carries 30000 (ms). HikariPoolConstraintValidator reads this defensively as a
# String to avoid ConversionFailedException on the drift value. Alignment is delegated to
# feature-env-driven-runtime-configuration (APP_DATASOURCE_CONNECTION_TIMEOUT).
# milliseconds (or Spring Duration string when env-keys default overrides)
connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT}
# milliseconds
idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT}
# D3 (HIKARI-CFG-C2): must be several seconds shorter than the DB/infrastructure idle
# timeout (DB wait_timeout, PgBouncer idle_transaction_timeout, firewall NAT timeout).
# Current default (30 min) is a placeholder until the actual DB wait_timeout is confirmed
# (see §Claims "DB wait_timeout 미확인" — needs-confirmation). Recommended: DB_idle_limit
# minus at least 60 s as a conservative margin. Value owner: feature-env-driven.
# milliseconds
max-lifetime: ${APP_DATASOURCE_POOL_MAX_LIFETIME}
# D4 (HIKARI-CFG-C4): greenfield — ping idle connections to prevent NAT/firewall/DB
# idle-kill from silently dropping them. Constraint: keepalive-time < max-lifetime
# (enforced by HikariPoolConstraintValidator). Provisional literal 120 000 ms (2 min);
# adjust once actual DB/firewall idle timeout is confirmed (§Claims).
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
# New env key APP_DATASOURCE_KEEPALIVE_TIME registration delegated to
# feature-env-driven-runtime-configuration.
keepalive-time: 120000
# D5 (HIKARI-CFG-C5): greenfield — enable connection leak early warning. Value must be
# >= 2000 ms to activate (enforced by HikariPoolConstraintValidator; 0 = disabled).
# Provisional literal 30 000 ms (30 s) — chosen well above the estimated longest normal
# transaction (~5 s) to avoid false positives on legitimate slow operations.
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
# New env key APP_DATASOURCE_LEAK_DETECTION_THRESHOLD delegated to
# feature-env-driven-runtime-configuration.
leak-detection-threshold: 30000
# D7 (HIKARI-CFG-C6): greenfield — must be < connection-timeout (HIKARI-CFG-C6),
# enforced by HikariPoolConstraintValidator. Resolves VALIDATION_TIMEOUT_CONFLICT:
# HikariCP default 5000 ms equals connection-timeout 5 s / 5000 ms → constraint
# violation. Provisional literal 3000 ms satisfies the constraint for connection-timeout
# values >= 3001 ms.
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
# New env key APP_DATASOURCE_VALIDATION_TIMEOUT delegated to
# feature-env-driven-runtime-configuration.
validation-timeout: 3000
# D6 (HIKARI-CFG-C7): greenfield — keep positive (default = 1 ms) to fail fast when DB
# is unavailable at startup. Aligns with runtime-health startup validation and project-note
# §9 "잘못된 env 값 startup fail-fast" policy. Negative value disables fail-fast (allowed
# only in orchestration environments where DB may start after the app — coordinate with
# runtime-health-lifecycle branch).
# UNSUPPORTED_IMPL_DECISION: literal value is a provisional policy default.
# New env key APP_DATASOURCE_INIT_FAIL_TIMEOUT delegated to
# feature-env-driven-runtime-configuration.
initialization-fail-timeout: 1
# ---------------------------------------------------------------------------
# D8 (feature-database-connection-pool-contract) — SLOW QUERY DETECTION
# POLICY ONLY — no library dependency added here. Implementation is DEFERRED
# pending local verification (TODO #3: confirm ParameterTransformer masking
# applies to slow-query listener output).
#
# Baseline (app layer, param-safe):
# datasource-proxy SlowQueryListener + ParameterTransformer ([REDACTED] masking).
# Requires the datasource-proxy / spring-boot-data-source-decorator dependency
# and a @Bean ParameterTransformer; deferred until masking behavior on slow-query
# output is locally verified.
#
# Production augment (DBA-owned):
# DB-side log_min_duration_statement — parameters included in extended-protocol
# output (PostgreSQL official security warning). DBA controls and redacts.
#
# Dev only (PROD-FORBIDDEN):
# Hibernate SQL_SLOW (LOG_QUERIES_SLOWER_THAN_MS) logs materialized SQL with
# substituted parameters — violates the "SQL/param 로그 금지" hard rule from
# feature-persistence-failure-baseline. NEVER enable in prod.
#
# Rejected:
# P6Spy — no built-in masking API; effective SQL exposes parameters by default
# with no safe override. Rejected per D8 (#C3/#C4).
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Flyway forward-only migration (feature-migration-startup-contract D1/D2/D4).
# Flyway runs during context refresh (Spring Boot autoconfig + the
# migrationStartupRunner FlywayMigrationStrategy), BEFORE the app reports
# readiness — so migration is inherently readiness-gated (D5): a failed or
# in-progress migration can never serve traffic.
#
# The three options below are PINNED, not env-driven: they are forbidden under
# prod (D2/D4) and a static pin means a future Flyway/Spring Boot default flip
# cannot silently re-enable them (same reasoning as spring.mvc.problemdetails.
# enabled above). FlywayProdSafetyValidator fails the boot (exit 71) if any
# per-environment override re-enables them under the prod profile.
# ---------------------------------------------------------------------------
flyway:
# false: never auto-baseline an existing schema — keep the missing-migration
# safety net (FLYWAY-C6). Enabling under prod is forbidden (D4).
baseline-on-migrate: false
# false: reject out-of-order migrations — preserve cross-developer ordering
# consistency (FLYWAY-C5). Enabling under prod is forbidden (D4).
out-of-order: false
# true: keep Flyway `clean` (drops the whole schema) disarmed. Disabling this
# under prod is forbidden — clean is destructive.
clean-disabled: true
jpa:
hibernate:
# none | validate | update | create | create-drop
ddl-auto: ${APP_DATASOURCE_DDL_AUTO}
# true | false
show-sql: ${APP_DATASOURCE_SHOW_SQL}
# true | false (don't enable in prod)
open-in-view: ${APP_DATASOURCE_OPEN_IN_VIEW}
properties:
hibernate:
# true | false (paired with show-sql)
format_sql: ${APP_DATASOURCE_FORMAT_SQL}
security:
oauth2:
resourceserver:
jwt:
# OIDC issuer (e.g., Keycloak realm URL). Spring Boot resolves JWKS from
# /.well-known/openid-configuration at this URI.
issuer-uri: ${APP_SECURITY_JWT_ISSUER}
# validated against the JWT `aud` claim; blank disables the check
audiences: ${APP_SECURITY_JWT_AUDIENCE}
main:
# off | console | log
banner-mode: ${SPRING_BANNER_MODE}
# true | false
lazy-initialization: ${SPRING_MAIN_LAZY_INITIALIZATION}
# true | false
log-startup-info: ${SPRING_MAIN_LOG_STARTUP_INFO}
threads:
virtual:
# true | false (Java 21 virtual threads for Tomcat request handlers)
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
servlet:
multipart:
# feature-api-contract-baseline D8: bound request body size so an oversized
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
# GlobalExceptionHandler#handleMaxUploadSizeExceededException), never a raw 500.
# Multipart-specific upload limits (UPLOAD_SIZE_EXCEEDED) are refined by
# feature-file-resource-handling-contract.
max-file-size: ${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:10MB}
max-request-size: ${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:10MB}
# Jackson deserialization policy (feature-boundary-validation-mapping-contract B1).
# Every request DTO crosses this boundary; the switches make malformed payloads
# fail at the edge rather than silently coercing or dropping fields.
jackson:
deserialization:
# true | false (Jackson 2.13+ default; reject unknown JSON keys)
fail-on-unknown-properties: ${SPRING_JACKSON_DESER_FAIL_ON_UNKNOWN_PROPERTIES}
# true | false (block JSON null → primitive 0/false coercion)
fail-on-null-for-primitives: ${SPRING_JACKSON_DESER_FAIL_ON_NULL_FOR_PRIMITIVES}
# true | false (surface JSON containing fields the target @JsonIgnore'd)
fail-on-ignored-properties: ${SPRING_JACKSON_DESER_FAIL_ON_IGNORED_PROPERTIES}
# Serialization output policy (feature-schema-serialization-contract D2/D3).
# These mirror current defaults but are pinned so a future Spring Boot default
# flip cannot silently break the datetime / decimal wire contract — the same
# reasoning as spring.mvc.problemdetails.enabled above.
datatype:
enum:
# true | false (false = Jackson default; unknown enum -> throw, not null)
read-unknown-enum-values-as-null: ${SPRING_JACKSON_DESER_READ_UNKNOWN_ENUM_VALUES_AS_NULL}
datetime:
# true | false (false: java.time -> ISO-8601 string via JavaTimeModule, D2)
write-dates-as-timestamps: ${SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS}
lifecycle:
# duration: 30s | 1m | 500ms
timeout-per-shutdown-phase: ${APP_SERVER_SHUTDOWN_TIMEOUT}
# ---------------------------------------------------------------------------
# Actuator / management — MERGED ownership at integration:
# - feature-management-actuator-security-contract owns server.port, the exposure
# allowlist/exclude, health show-details, shutdown-disabled, and info.
# - feature-runtime-health-lifecycle-contract owns the health probe SHAPE
# (probes.enabled + the liveness/readiness/startup groups + dependency taxonomy).
# ---------------------------------------------------------------------------
management:
# feature-management-actuator-security-contract D1: separate management port so actuator
# endpoints are not exposed on the same socket as the app API. Default: 9001.
server:
port: ${MANAGEMENT_SERVER_PORT:9001}
endpoints:
web:
# D2: production allowlist — only safe, scrape-friendly endpoints are exposed.
exposure:
include: health,prometheus,info,loggers
# D2/D4/D5: explicitly excluded dangerous endpoints (env leaks secrets,
# heapdump/threaddump = memory forensics, shutdown = remote kill, configprops = secret leak).
exclude: env,configprops,heapdump,threaddump,shutdown
endpoint:
health:
# D8: never expose health details to unauthenticated callers.
show-details: when-authorized
# feature-runtime-health-lifecycle-contract: expose the Kubernetes-ready
# liveness/readiness/startup probe paths.
probes:
enabled: true
group:
# Liveness: JVM can continue (OOM → liveness DOWN → pod restart).
# Only livenessState is included; dependency health MUST NOT be here —
# a DOWN DB must never trigger a pod restart when the JVM is healthy.
liveness:
include: livenessState
# Readiness: ready to serve traffic AND all REQUIRED dependencies up.
# Required: db (primary DB — auto-contributed by Spring Boot DataSource).
# Optional: cache / broker / notification adapters are NOT in this group
# (they are conditional or optional per the dependency taxonomy).
readiness:
include: readinessState,db
# Startup: startup/migration validation complete.
# readinessState acts as the startup completion gate — it flips UP only
# after the context is fully initialized (Flyway migration included).
startup:
include: readinessState
shutdown:
# D4: shutdown endpoint disabled globally — even if somehow exposed, it cannot be invoked.
access: none
info:
build:
# Build-info only (no env leak).
enabled: true
server:
# integer 1-65535
port: ${APP_SERVER_PORT}
# graceful | immediate
shutdown: ${APP_SERVER_SHUTDOWN}
# none | native | framework (trust X-Forwarded-* behind LB/proxy)
forward-headers-strategy: ${APP_SERVER_FORWARD_HEADERS_STRATEGY}
tomcat:
threads:
# integer >= 1
max: ${APP_SERVER_TOMCAT_MAX_THREADS}
# integer >= 0
min-spare: ${APP_SERVER_TOMCAT_MIN_SPARE_THREADS}
# integer >= 0 (OS-level backlog queue depth)
accept-count: ${APP_SERVER_TOMCAT_ACCEPT_COUNT}
# integer >= 1 (hard cap on simultaneously open connections)
max-connections: ${APP_SERVER_TOMCAT_MAX_CONNECTIONS}
# duration: 20s | 1m
connection-timeout: ${APP_SERVER_TOMCAT_CONNECTION_TIMEOUT}
compression:
# true | false
enabled: ${APP_SERVER_COMPRESSION_ENABLED}
# bytes or sized: 1024 | 1KB | 2KB
min-response-size: ${APP_SERVER_COMPRESSION_MIN_RESPONSE_SIZE}
logging:
level:
# TRACE | DEBUG | INFO | WARN | ERROR | OFF
root: ${APP_LOG_LEVEL_ROOT}
dev.caskeleton: ${APP_LOG_LEVEL_APP}
org.springframework: ${APP_LOG_LEVEL_SPRING}
org.springframework.web: ${APP_LOG_LEVEL_WEB}
# DEBUG here prints SQL once JPA/jdbc is wired in
org.hibernate.SQL: ${APP_LOG_LEVEL_SQL}
# Module-scoped knobs. Each block is bound into a *Settings @ConfigurationProperties
# record in the corresponding module, which is where allowed-value validation lives.
ca-skeleton:
bootstrap:
# required, non-blank — startup fails if blank (see BootstrapSettings)
app-name: ${APP_NAME}
runtime:
# feature-env-driven-runtime-configuration D8: enforced at startup by
# StartupSafetyValidator. The two prod-unsafe toggles fail startup if true under
# the prod profile; multi-instance fails startup if its coordination beans are absent.
# true | false (internal error detail in responses — forbidden under prod)
error-detail-exposure-enabled: ${APP_ERROR_DETAIL_EXPOSURE_ENABLED:false}
# true | false (request/response body capture in logs — forbidden under prod)
log-body-capture-enabled: ${APP_LOG_BODY_CAPTURE_ENABLED:false}
# true | false (requires the 5 instance-coordination beans when true)
multi-instance-enabled: ${APP_MULTI_INSTANCE_ENABLED:false}
# true | false (whether to run Flyway migrations automatically at startup)
migration-on-startup: ${APP_MIGRATION_ON_STARTUP:true}
# feature-distributed-lock-contract D5 — distributed lock acquisition contract (try-lock +
# finite wait + lease TTL). Bound to LockSettings (adapter-persistence). Plain values, NOT
# APP_* env keys — new env-key registration is feature-env-driven-runtime-configuration's
# domain (out of scope for this branch); code defaults in LockSettings mirror these.
lock:
wait-time: 3s
lease-ttl: 30s
presentation:
# feature-api-contract-baseline D2: API version prefix. Default is the URI
# prefix "/v1" (major-version path, AIP-185); override via env, or set "" for
# no prefix. The supplemental "X-Api-Version" header never overrides the path.
api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}
rate-limit:
# feature-rate-limit-idempotency-contract D1/§G. enabled is env-driven
# (restart-only); limit/window are the fixed-window mechanism's literal tuning
# parameters (no env key — UNSUPPORTED_IMPL per-key counter, single-node D5).
enabled: ${APP_RATE_LIMIT_ENABLED}
limit: 100
window: 1s
# RateLimiter strategy: fixed-window (default) | (extend: sliding-window | token-bucket)
algorithm: fixed-window
# Client IP source for unauthenticated rate-limit keys:
# remote-addr-only (safe default) | forwarded-headers-trusted (only behind trusted ingress/LB)
client-ip-mode: ${APP_RATE_LIMIT_CLIENT_IP_MODE:remote-addr-only}
idempotency:
# feature-rate-limit-idempotency-contract D6/§E. ttl is env-driven (<=72h,
# validated in IdempotencyProperties); reaper-interval is literal operational tuning.
ttl: ${APP_IDEMPOTENCY_TTL}
reaper-interval: 10m
async:
# feature-background-job-async-contract D7 — @Async ThreadPoolTaskExecutor pool sizing.
# Registry SSOT: docs/registries/env-keys.yaml (APP_ASYNC_EXECUTOR_* rows 1337-1377).
# Bound into AsyncExecutorSettings @ConfigurationProperties(prefix = "ca-skeleton.async.executor").
# Bounded queue is mandatory (D7) — an unbounded queue makes max-size unreachable.
executor:
# int >= 1 (positive_int). Always-alive worker count.
core-size: ${APP_ASYNC_EXECUTOR_CORE_SIZE:10}
# int >= core-size (positive_int_ge_core). Hard ceiling on workers.
max-size: ${APP_ASYNC_EXECUTOR_MAX_SIZE:50}
# int in 1..<Integer.MAX_VALUE (positive_int_bounded). Bounded backlog depth.
queue-capacity: ${APP_ASYNC_EXECUTOR_QUEUE_CAPACITY:200}
outbox:
# feature-domain-event-outbox-contract I11 — all six values are literal defaults;
# NO env placeholders (spec: 신규 env key 없음). Bound into OutboxProperties.
# true | false — enable/disable the relay scheduler (OutboxRelayScheduler)
relay-enabled: true
# ISO-8601 duration — how often the relay polls for pending rows
poll-interval: PT5S
# integer >= 1 — maximum rows claimed per relay cycle
batch-size: 20
# ISO-8601 duration — IN_FLIGHT orphan visibility window (I6: next_attempt_at reuse)
in-flight-timeout: PT5M
# ISO-8601 duration — read also by adapter-persistence OutboxReaper @Scheduled
reaper-interval: PT10M
# ISO-8601 duration — PUBLISHED row retention before reaper deletes them (I3)
# read also by adapter-persistence OutboxReaper via ${ca-skeleton.outbox.published-retention:P7D}
published-retention: P7D
security:
# REQUIRED; startup fails if blank
issuer-uri: ${APP_SECURITY_JWT_ISSUER}
# blank to skip audience check
audience: ${APP_SECURITY_JWT_AUDIENCE}
# comma-separated list (Spring binds to List<String>)
public-paths: ${SECURITY_PUBLIC_PATHS}
authz:
# feature-authentication-authorization-contract D2/D3/D8: app-side role→permission
# mapping (the default source; IdP-issued permission claims are an out-of-scope
# alternative). Keys are RAW IdP role names (no ROLE_ prefix — that prefix only
# exists on Spring authorities, not on the principal's raw role set), looked up
# case-insensitively. Values are explicitly enumerated `resource:action` permissions
# (no wildcards — least-privilege, OWASP-AUTHZ-C4). The values below are the
# sample-portfolio demonstration (§5): `user` may read/write, only `admin` may close.
role-permissions:
user: worklog:read,worklog:write
admin: worklog:read,worklog:write,worklog:close
cors:
# true | false
enabled: ${APP_SECURITY_CORS_ENABLED}
# comma-separated
allowed-origins: ${APP_SECURITY_CORS_ORIGINS}
# comma-separated; empty -> defaults
allowed-methods: ${APP_SECURITY_CORS_ALLOWED_METHODS}
# comma-separated; "*" allows any
allowed-headers: ${APP_SECURITY_CORS_ALLOWED_HEADERS}
# true | false
allow-credentials: ${APP_SECURITY_CORS_ALLOW_CREDENTIALS}
# seconds
max-age-seconds: ${APP_SECURITY_CORS_MAX_AGE}
logging:
file:
# true | false (wraps console + adds rolling JSON file appender)
enabled: ${APP_LOG_FILE_ENABLED}
# relative (to src/) or absolute
path: ${APP_LOG_FILE_PATH}
# size with unit: KB | MB | GB
max-size: ${APP_LOG_FILE_MAX_SIZE}
# integer >= 1
max-history: ${APP_LOG_FILE_MAX_HISTORY}
# size with unit or 0
total-size-cap: ${APP_LOG_FILE_TOTAL_SIZE_CAP}
async:
# true | false (AsyncAppender wrapper for non-blocking I/O)
enabled: ${APP_LOG_ASYNC_ENABLED}
# integer >= 1
queue-size: ${APP_LOG_ASYNC_QUEUE_SIZE}
# integer >= 0 (0 = never drop)
discarding-threshold: ${APP_LOG_ASYNC_DISCARDING_THRESHOLD}
json:
# IANA timezone (UTC | Asia/Seoul | ...) or "default"
timezone: ${APP_LOG_JSON_TIMEZONE}
# ISO 8601 pattern
timestamp-pattern: ${APP_LOG_JSON_TIMESTAMP_PATTERN}
# true | false (file/method/line — expensive)
include-caller-data: ${APP_LOG_JSON_INCLUDE_CALLER_DATA}
# integer; 0 = full name, positive = abbreviated
logger-name-length: ${APP_LOG_JSON_LOGGER_NAME_LENGTH}
# float in [0.0, 1.0] — keep-probability for <=INFO logs (prod 0.1 = 10% sampling;
# WARN/ERROR always 100%). Consumed by SamplingTurboFilter via logback <springProperty>.
sampling-rate: ${APP_LOG_SAMPLING_RATE}
privacy:
# secret-tier HMAC salt for user_principal pseudonymization (DRIFT-6; algorithm SSOT
# feature-data-retention-privacy-contract). Blank -> PrivacySettings warns + uses a dev
# sentinel; prod MUST supply a real secret-manager value.
pseudonymization-salt: ${APP_PRIVACY_PSEUDONYMIZATION_SALT:}
# feature-distributed-tracing-contract D1/D4/D6 — tracing seam settings.
# The OTel/Micrometer tracer runtime is active in this repo (Plan A — seam activated):
# micrometer-tracing-bridge-otel + opentelemetry-exporter-otlp are wired in
# app-bootstrap/build.gradle. The exporter remains off while OTEL_EXPORTER_OTLP_ENDPOINT
# is blank (url_or_empty default). A fork can supply the endpoint via env or override
# SpanErrorRecorder with its own bean.
# D4 disabled-fallback: even when enabled=false, RequestLoggingFilter always
# generates a W3C traceparent so meta.traceId and log trace_id are never null.
# D6 per-profile defaults: prod=0.01 / staging=0.10 / dev·local=1.0.
# APP_TRACING_SAMPLE_RATE overrides the per-profile default when set.
tracing:
# true | false (boolean_strict). Tracing seam on/off.
enabled: ${APP_TRACING_ENABLED:true}
# float in [0.0, 1.0]. Per-profile override (D6 float_between_0_and_1 validation at startup).
# blank = per-profile default via TracingSampleRateResolver (D-1 ISSUE-1 fix).
sample-rate: ${APP_TRACING_SAMPLE_RATE:}
exporter:
# url_or_empty: blank = exporter off (D1 SEAM default); non-blank must be a valid URL.
otlp-endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:}
# feature-integration-adapter-templates — optional integration adapter toggles.
# The `app.<domain>.<adapter>.enabled` flags feed Spring @ConditionalOnProperty (Layer 1):
# disabled (default): messaging binds a fail-fast Disabled* sentinel bean; cache/notification
# contribute nothing and fail fast in their router (CacheStoreRouter / RoutingNotifier) on
# unbound access. Enabled registers the real adapter
# (which needs its project-supplied integration client bean). Domain namespace, NOT a
# generic `app.adapter.*` prefix (branch-note §Audit A1). Env keys are the registry SSOT.
app:
cache:
redis:
# true | false (boolean_strict). Redis cache adapter on/off.
enabled: ${APP_CACHE_REDIS_ENABLED}
# Logical-cache-name → backendId routing (CacheStoreRouter). No keys by default —
# forks add e.g. `bindings: { worklog: redis }` or env APP_CACHE_BINDINGS_WORKLOG=redis.
# A binding to a backend that is not enabled fails startup (Layer 3 moved to router).
messaging:
# Active message broker id (e.g. kafka). Blank = messaging disabled (fail-fast on use).
# Selects the single MessageBroker; adding a broker is new files only (MessagingConfig).
broker: ${APP_MESSAGING_BROKER}
kafka:
# CSV of host:port; required (non-empty) only when broker=kafka. Bound +
# validated by KafkaAdapterSettings (format) + KafkaAdapterConfig (required-when-active).
brokers: ${APP_MESSAGING_KAFKA_BROKERS:}
notification:
# Active provider id per kind; blank = that kind disabled (fail-fast on use).
# Add a provider = new files only (NotificationConfig); select it here.
slack:
provider: ${APP_NOTIFICATION_SLACK_PROVIDER}
email:
provider: ${APP_NOTIFICATION_EMAIL_PROVIDER}
# ---------------------------------------------------------------------------
# feature-outbound-http-client-baseline D5/D3/D7
# Registry SSOT: docs/registries/env-keys.yaml (APP_OUTBOUND_HTTP_* rows 489570)
# Bound into OutboundHttpSettings @ConfigurationProperties(prefix = "app.outbound.http").
# ---------------------------------------------------------------------------
outbound:
http:
# duration (e.g. 2s). REQUIRED — non-zero (spring_duration_shorthand_non_zero).
connect-timeout: ${APP_OUTBOUND_HTTP_CONNECT_TIMEOUT}
# duration (e.g. 5s). REQUIRED — non-zero.
read-timeout: ${APP_OUTBOUND_HTTP_READ_TIMEOUT}
# duration (e.g. 10s). REQUIRED — non-zero (deadline budget for the whole call incl. retries).
global-call-timeout: ${APP_OUTBOUND_HTTP_GLOBAL_CALL_TIMEOUT}
# true | false (boolean_strict). Resilience4j retry — default disabled (D3).
retry-enabled: ${APP_OUTBOUND_HTTP_RETRY_ENABLED:false}
# retry 튜닝 (retry-enabled=true 일 때 적용). 기본값 = 기존 하드코딩 동작 보존.
retry:
# int >= 1 (positive_int). 총 시도 횟수(최초 시도 포함).
max-attempts: ${APP_OUTBOUND_HTTP_RETRY_MAX_ATTEMPTS:3}
# duration (spring_duration_shorthand_non_zero). exponential backoff 시작 간격.
initial-backoff: ${APP_OUTBOUND_HTTP_RETRY_INITIAL_BACKOFF:100ms}
# double >= 1.0 (double_ge_1). exponential backoff 배수.
backoff-multiplier: ${APP_OUTBOUND_HTTP_RETRY_BACKOFF_MULTIPLIER:2.0}
# true | false (boolean_strict). Resilience4j circuit breaker — default disabled.
circuit-breaker-enabled: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_ENABLED:false}
# circuit-breaker 튜닝 (circuit-breaker-enabled=true 일 때 적용). 기본값 = Resilience4j ofDefaults().
circuit-breaker:
# float in (0, 100] (float_in_0_exclusive_to_100). open 전환 실패율 임계치(%).
failure-rate-threshold: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_FAILURE_RATE_THRESHOLD:50}
# int >= 1 (positive_int). COUNT_BASED sliding window 크기.
sliding-window-size: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_SLIDING_WINDOW_SIZE:100}
# int >= 1 (positive_int). 실패율 계산을 시작하는 최소 호출 수.
minimum-number-of-calls: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_MINIMUM_NUMBER_OF_CALLS:100}
# duration (spring_duration_shorthand_non_zero). open 상태 유지 시간.
wait-duration-in-open-state: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_WAIT_DURATION_IN_OPEN_STATE:60s}
# int >= 1 (positive_int). half-open 상태에서 허용하는 시험 호출 수.
permitted-calls-in-half-open: ${APP_OUTBOUND_HTTP_CIRCUIT_BREAKER_PERMITTED_CALLS_IN_HALF_OPEN:10}
# data size (e.g. 10MB). Streaming threshold — buffered reads above this fail (D7).
response-size-limit: ${APP_OUTBOUND_HTTP_RESPONSE_SIZE_LIMIT:10MB}
@@ -0,0 +1,194 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- Early logging initializes before dotenv-backed application.yml placeholders are reliable. -->
<springProperty scope="context" name="APP_NAME"
source="APP_NAME" defaultValue="ca-skeleton"/>
<springProperty scope="context" name="APP_PROFILE"
source="SPRING_PROFILES_ACTIVE" defaultValue="local"/>
<springProperty scope="context" name="FILE_ENABLED"
source="APP_LOG_FILE_ENABLED" defaultValue="false"/>
<springProperty scope="context" name="FILE_PATH"
source="APP_LOG_FILE_PATH" defaultValue="logs/ca-skeleton.json"/>
<springProperty scope="context" name="FILE_MAX_SIZE"
source="APP_LOG_FILE_MAX_SIZE" defaultValue="100MB"/>
<springProperty scope="context" name="FILE_MAX_HISTORY"
source="APP_LOG_FILE_MAX_HISTORY" defaultValue="14"/>
<springProperty scope="context" name="FILE_TOTAL_SIZE_CAP"
source="APP_LOG_FILE_TOTAL_SIZE_CAP" defaultValue="3GB"/>
<springProperty scope="context" name="ASYNC_ENABLED"
source="APP_LOG_ASYNC_ENABLED" defaultValue="true"/>
<springProperty scope="context" name="ASYNC_QUEUE_SIZE"
source="APP_LOG_ASYNC_QUEUE_SIZE" defaultValue="512"/>
<springProperty scope="context" name="ASYNC_DISCARDING_THRESHOLD"
source="APP_LOG_ASYNC_DISCARDING_THRESHOLD" defaultValue="20"/>
<springProperty scope="context" name="JSON_TIMEZONE"
source="APP_LOG_JSON_TIMEZONE" defaultValue="UTC"/>
<springProperty scope="context" name="JSON_TIMESTAMP_PATTERN"
source="APP_LOG_JSON_TIMESTAMP_PATTERN"
defaultValue="yyyy-MM-dd'T'HH:mm:ss.SSSXXX"/>
<springProperty scope="context" name="JSON_INCLUDE_CALLER_DATA"
source="APP_LOG_JSON_INCLUDE_CALLER_DATA" defaultValue="false"/>
<springProperty scope="context" name="JSON_LOGGER_NAME_LENGTH"
source="APP_LOG_JSON_LOGGER_NAME_LENGTH" defaultValue="0"/>
<!-- D5/D8: ≤INFO sampling rate (prod 0.1 = 10%); WARN/ERROR always kept. -->
<springProperty scope="context" name="LOG_SAMPLING_RATE"
source="APP_LOG_SAMPLING_RATE" defaultValue="1.0"/>
<!-- Redaction Layer 1 (DRIFT-2): %maskedMsg masks secrets in the human-readable pattern,
the SAME catalog (LogMaskingPatterns) the JSON decorator uses. -->
<conversionRule conversionWord="maskedMsg"
class="dev.caskeleton.bootstrap.logging.SecretMaskingMessageConverter"/>
<!-- Sampling Policy (final): drop a share of ≤INFO events; WARN/ERROR pass unconditionally. -->
<turboFilter class="dev.caskeleton.bootstrap.logging.SamplingTurboFilter">
<rate>${LOG_SAMPLING_RATE}</rate>
</turboFilter>
<turboFilter class="dev.caskeleton.bootstrap.logging.StartupFailureSpringBootLogFilter"/>
<!-- ===================== Console appender (per profile, D10) ===================== -->
<!-- local/dev: human-readable PatternLayout, with %maskedMsg redaction (DX exception). -->
<springProfile name="local,dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} req=%X{request_id:-} trace=%X{trace_id:-} user=%X{user_principal:-} - %maskedMsg%n</pattern>
</encoder>
</appender>
</springProfile>
<!-- everything else (staging/prod/default/test): structured JSON + Layer 1 masking decorator. -->
<springProfile name="!local &amp; !dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeContext>false</includeContext>
<customFields>{"app":"${APP_NAME}","profile":"${APP_PROFILE}"}</customFields>
<includeMdcKeyName>trace_id</includeMdcKeyName>
<includeMdcKeyName>span_id</includeMdcKeyName>
<includeMdcKeyName>request_id</includeMdcKeyName>
<includeMdcKeyName>correlation_id</includeMdcKeyName>
<includeMdcKeyName>user_principal</includeMdcKeyName>
<timeZone>${JSON_TIMEZONE}</timeZone>
<timestampPattern>${JSON_TIMESTAMP_PATTERN}</timestampPattern>
<includeCallerData>${JSON_INCLUDE_CALLER_DATA}</includeCallerData>
<shortenedLoggerNameLength>${JSON_LOGGER_NAME_LENGTH}</shortenedLoggerNameLength>
<jsonGeneratorDecorator
class="dev.caskeleton.bootstrap.logging.SecretMaskingJsonGeneratorDecorator"/>
</encoder>
</appender>
</springProfile>
<!-- ===================== File appender (always JSON, gated) ====================== -->
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
<key>FILE_ENABLED</key><value>true</value>
</condition>
<if>
<then>
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${FILE_PATH}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${FILE_PATH}.%d{yyyy-MM-dd}.%i.gz</fileNamePattern>
<maxFileSize>${FILE_MAX_SIZE}</maxFileSize>
<maxHistory>${FILE_MAX_HISTORY}</maxHistory>
<totalSizeCap>${FILE_TOTAL_SIZE_CAP}</totalSizeCap>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeContext>false</includeContext>
<customFields>{"app":"${APP_NAME}","profile":"${APP_PROFILE}"}</customFields>
<includeMdcKeyName>trace_id</includeMdcKeyName>
<includeMdcKeyName>span_id</includeMdcKeyName>
<includeMdcKeyName>request_id</includeMdcKeyName>
<includeMdcKeyName>correlation_id</includeMdcKeyName>
<includeMdcKeyName>user_principal</includeMdcKeyName>
<timeZone>${JSON_TIMEZONE}</timeZone>
<timestampPattern>${JSON_TIMESTAMP_PATTERN}</timestampPattern>
<includeCallerData>${JSON_INCLUDE_CALLER_DATA}</includeCallerData>
<shortenedLoggerNameLength>${JSON_LOGGER_NAME_LENGTH}</shortenedLoggerNameLength>
<jsonGeneratorDecorator
class="dev.caskeleton.bootstrap.logging.SecretMaskingJsonGeneratorDecorator"/>
</encoder>
</appender>
</then>
</if>
<!-- ===================== Async wrap (drop-counting, DRIFT-5) ===================== -->
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
<key>ASYNC_ENABLED</key><value>true</value>
</condition>
<if>
<then>
<appender name="ASYNC_CONSOLE" class="dev.caskeleton.bootstrap.logging.MetricsAsyncAppender">
<queueSize>${ASYNC_QUEUE_SIZE}</queueSize>
<discardingThreshold>${ASYNC_DISCARDING_THRESHOLD}</discardingThreshold>
<neverBlock>false</neverBlock>
<appender-ref ref="CONSOLE"/>
</appender>
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
<key>FILE_ENABLED</key><value>true</value>
</condition>
<if>
<then>
<appender name="ASYNC_FILE" class="dev.caskeleton.bootstrap.logging.MetricsAsyncAppender">
<queueSize>${ASYNC_QUEUE_SIZE}</queueSize>
<discardingThreshold>${ASYNC_DISCARDING_THRESHOLD}</discardingThreshold>
<neverBlock>false</neverBlock>
<appender-ref ref="JSON_FILE"/>
</appender>
</then>
</if>
</then>
</if>
<!-- ============================== Root =============================== -->
<!-- <if> may not be nested inside <root> (logback IfNestedWithinSecondPhaseElementSC); wrap
each <root> in a top-level <condition>+<if>, one per ASYNC×FILE combination. -->
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
<key>ASYNC_ENABLED</key><value>true</value>
</condition>
<if>
<then>
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
<key>FILE_ENABLED</key><value>true</value>
</condition>
<if>
<then>
<root level="INFO">
<appender-ref ref="ASYNC_CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/>
</root>
</then>
<else>
<root level="INFO">
<appender-ref ref="ASYNC_CONSOLE"/>
</root>
</else>
</if>
</then>
<else>
<condition class="ch.qos.logback.core.boolex.PropertyEqualityCondition">
<key>FILE_ENABLED</key><value>true</value>
</condition>
<if>
<then>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="JSON_FILE"/>
</root>
</then>
<else>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</else>
</if>
</else>
</if>
</configuration>
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.outbound;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter;
import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage;
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
import dev.caskeleton.shared.error.AdapterDisabledException;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
/**
* Layer 3 (D4) — a disabled optional adapter that is nonetheless invoked must fail fast with {@link
* AdapterDisabledException}, never a silent no-op or timeout wait (required_test {@code
* adapter-contract:adapter-disabled-runtime-call}).
*
* <p>Notification uses the router-fail-fast shape (no per-channel {@code Disabled*Notifier}
* sentinel): an unbound route on a {@link RoutingNotifier} with no providers/routes throws {@link
* AdapterDisabledException} — mirrors the cache D4 contract.
*/
class DisabledAdapterSentinelTest {
private static final Notification STUB_NOTIFICATION =
new Notification("test@example.com", "s", "b");
@Test
void disabledMessagingFailsFast() {
assertThatThrownBy(
() -> new DisabledMessagePublisher().publish(new OutboundMessage("t", "k", "p")))
.isInstanceOf(AdapterDisabledException.class)
.extracting("adapterName")
.isEqualTo("messaging");
}
@Test
void unwiredCacheGetFailsFast() {
assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of()).get("worklog", "k"))
.isInstanceOf(AdapterDisabledException.class)
.extracting("adapterName")
.isEqualTo("cache");
}
@Test
void unwiredCachePutFailsFast() {
assertThatThrownBy(() -> new CacheStoreRouter(List.of(), Map.of()).put("worklog", "k", "v"))
.isInstanceOf(AdapterDisabledException.class);
}
@Test
void unwiredNotificationEmailFailsFast() {
// router-fail-fast: no providers, no routes → AdapterDisabledException on first call
assertThatThrownBy(
() -> new RoutingNotifier(List.of(), Map.of()).notify(Channel.EMAIL, STUB_NOTIFICATION))
.isInstanceOf(AdapterDisabledException.class)
.extracting("adapterName")
.isEqualTo("notification");
}
@Test
void unwiredNotificationSlackFailsFast() {
assertThatThrownBy(
() ->
new RoutingNotifier(List.of(), Map.of())
.notify(Channel.SLACK, "alerts", STUB_NOTIFICATION))
.isInstanceOf(AdapterDisabledException.class)
.extracting("adapterName")
.isEqualTo("notification");
}
}
@@ -0,0 +1,263 @@
package dev.caskeleton.adapter.outbound;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.cache.CacheRouterConfig;
import dev.caskeleton.adapter.outbound.cache.core.CacheBackend;
import dev.caskeleton.adapter.outbound.cache.core.CacheStore;
import dev.caskeleton.adapter.outbound.cache.core.CacheStoreRouter;
import dev.caskeleton.adapter.outbound.cache.redis.RedisCacheAdapterConfig;
import dev.caskeleton.adapter.outbound.cache.redis.RedisClient;
import dev.caskeleton.adapter.outbound.messaging.MessagingConfig;
import dev.caskeleton.adapter.outbound.messaging.core.DisabledMessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker;
import dev.caskeleton.adapter.outbound.messaging.core.MessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessagePublisher;
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaAdapterConfig;
import dev.caskeleton.adapter.outbound.messaging.kafka.KafkaSender;
import dev.caskeleton.adapter.outbound.messaging.outbox.DisabledOutboxMessagePublisher;
import dev.caskeleton.adapter.outbound.notification.NotificationConfig;
import dev.caskeleton.adapter.outbound.notification.core.NotificationProvider;
import dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier;
import dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailClient;
import dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig;
import dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackClient;
import dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig;
import dev.caskeleton.adapter.outbound.support.OutboundSupportConfig;
import dev.caskeleton.application.notification.Channel;
import dev.caskeleton.application.notification.Notification;
import dev.caskeleton.application.notification.NotificationPort;
import dev.caskeleton.application.outbox.OutboxMessagePublishPort;
import dev.caskeleton.shared.error.AdapterDisabledException;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Layer 1 (D2) — {@code @ConditionalOnProperty} bean-gating contract (required_test {@code
* adapter-contract:{redis,kafka,slack-webhook,google-email}-disabled-default}).
*
* <p>Asserts that with the default (env absent → disabled) the real adapter bean count is 0 and the
* router fails fast on unbound access; and that flipping the enable flag (with the integration
* client supplied) registers the real adapter and routes correctly. Also validates OCP: a second
* backend plugs in via new files only without changing existing configs.
*
* <p>Notification: uses the router-fail-fast shape (mirrors cache D4). No per-channel {@code
* Disabled*Notifier} sentinel — unbound route → {@link AdapterDisabledException} from {@link
* RoutingNotifier}.
*/
class OptionalAdapterBeanGatingTest {
private static final Notification STUB_NOTIFICATION =
new Notification("test@example.com", "test", "body");
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of())
.withUserConfiguration(
OutboundSupportConfig.class,
MessagingConfig.class,
KafkaAdapterConfig.class,
RedisCacheAdapterConfig.class,
CacheRouterConfig.class,
NotificationConfig.class,
SlackNotificationAdapterConfig.class,
GoogleEmailNotificationAdapterConfig.class,
StubClientsConfig.class);
@Test
void allOptionalAdaptersAreDisabledByDefaultAndTheAppStillStarts() {
// L262: an optional adapter must NOT become a required startup dependency.
runner.run(
context -> {
assertThat(context).hasNotFailed();
// real provider beans absent (Layer 1 — disabled, contributes nothing)
assertThat(context.getBeansOfType(MessageBroker.class)).isEmpty();
assertThat(context.getBeansOfType(CacheStore.class)).isEmpty();
assertThat(context.getBeansOfType(NotificationProvider.class)).isEmpty();
// messaging: fail-fast sentinels satisfy the ports (Layer 3 fallback)
assertThat(context.getBean(MessagePublisher.class))
.isInstanceOf(DisabledMessagePublisher.class);
assertThat(context.getBean(OutboxMessagePublishPort.class))
.isInstanceOf(DisabledOutboxMessagePublisher.class);
// cache D4: zero backends boot fine, unwired access fails fast in the router
CacheStoreRouter cacheRouter = context.getBean(CacheStoreRouter.class);
assertThatThrownBy(() -> cacheRouter.get("worklog", "k"))
.isInstanceOf(AdapterDisabledException.class);
// notification D4: zero providers boot fine, unbound route fails fast in RoutingNotifier
NotificationPort notificationPort = context.getBean(NotificationPort.class);
assertThat(notificationPort).isInstanceOf(RoutingNotifier.class);
assertThatThrownBy(() -> notificationPort.notify(Channel.EMAIL, STUB_NOTIFICATION))
.isInstanceOf(AdapterDisabledException.class)
.extracting("adapterName")
.isEqualTo("notification");
});
}
@Test
void kafkaSelectedRegistersTheBrokerAndBindsTheRealPublishers() {
runner
.withPropertyValues(
"app.messaging.broker=kafka", "app.messaging.kafka.brokers=broker-1:9092")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBeansOfType(MessageBroker.class)).hasSize(1);
assertThat(context.getBean(MessagePublisher.class))
.isInstanceOf(OutboundMessagePublisher.class);
assertThat(context.getBeansOfType(DisabledMessagePublisher.class)).isEmpty();
assertThat(context.getBeansOfType(DisabledOutboxMessagePublisher.class)).isEmpty();
});
}
@Test
void redisEnabledContributesTheBackendAndRoutesBoundLogicalCaches() {
runner
.withPropertyValues("app.cache.redis.enabled=true", "app.cache.bindings.worklog=redis")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBeansOfType(CacheStore.class)).hasSize(1);
CacheStoreRouter router = context.getBean(CacheStoreRouter.class);
assertThat(router.get("worklog", "k")).isEmpty();
});
}
@Test
void slackWebhookEnabledContributesTheProviderAndRoutesBoundNotifications() {
runner
.withPropertyValues(
"app.notification.slack-webhook.enabled=true",
"app.notification.routes.slack.default=slack-webhook")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBeansOfType(NotificationProvider.class)).hasSize(1);
NotificationPort port = context.getBean(NotificationPort.class);
assertThat(port).isInstanceOf(RoutingNotifier.class);
// stub SlackClient is a no-op — call succeeds without exception
assertThatCode(() -> port.notify(Channel.SLACK, STUB_NOTIFICATION))
.doesNotThrowAnyException();
});
}
@Test
void googleEmailEnabledContributesTheProviderAndRoutesBoundNotifications() {
runner
.withPropertyValues(
"app.notification.google-email.enabled=true",
"app.notification.routes.email.default=google-email")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBeansOfType(NotificationProvider.class)).hasSize(1);
NotificationPort port = context.getBean(NotificationPort.class);
assertThat(port).isInstanceOf(RoutingNotifier.class);
assertThatCode(() -> port.notify(Channel.EMAIL, STUB_NOTIFICATION))
.doesNotThrowAnyException();
});
}
@Test
void aSecondBackendPlugsInWithNewFilesOnlyAndBothRouteByLogicalName() {
// OCP proof: SecondBackendConfig simulates a future backend added as a NEW config
// only — RedisCacheAdapterConfig / CacheRouterConfig are not touched.
runner
.withUserConfiguration(SecondBackendConfig.class)
.withPropertyValues(
"app.cache.redis.enabled=true",
"app.cache.test-second.enabled=true",
"app.cache.bindings.worklog=redis",
"app.cache.bindings.session=test-second")
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBeansOfType(CacheStore.class)).hasSize(2);
CacheStoreRouter router = context.getBean(CacheStoreRouter.class);
assertThat(router.get("session", "k")).contains("from-second-backend");
assertThat(router.get("worklog", "k")).isEmpty();
});
}
@Test
void aBindingToADisabledBackendFailsStartup() {
runner
.withPropertyValues("app.cache.bindings.worklog=redis")
.run(context -> assertThat(context).hasFailed());
}
@Test
void aRouteBindingToADisabledProviderFailsStartup() {
// configuration contradiction: route references a provider that contributed no bean
runner
.withPropertyValues("app.notification.routes.email.default=google-email")
.run(context -> assertThat(context).hasFailed());
}
/** Simulates a forking project's additional cache backend — new files only. */
@Configuration
static class SecondBackendConfig {
@Bean
@ConditionalOnProperty(name = "app.cache.test-second.enabled", havingValue = "true")
CacheBackend secondBackend() {
return new CacheBackend() {
@Override
public String backendId() {
return "test-second";
}
@Override
public Optional<String> get(String key) {
return Optional.of("from-second-backend");
}
@Override
public void put(String key, String value) {}
};
}
}
/** Supplies the integration-seam client beans an enabled adapter requires. */
@Configuration
static class StubClientsConfig {
@Bean
KafkaSender kafkaSender() {
return message -> {};
}
@Bean
RedisClient redisClient() {
return new RedisClient() {
@Override
public Optional<String> read(String key) {
return Optional.empty();
}
@Override
public void write(String key, String value) {}
};
}
@Bean
SlackClient slackClient() {
return notification -> {};
}
@Bean
GoogleEmailClient googleEmailClient() {
return notification -> {};
}
}
}
@@ -0,0 +1,705 @@
package dev.caskeleton.bootstrap.architecture;
import static org.assertj.core.api.Assertions.assertThat;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.EvaluationResult;
import dev.caskeleton.bootstrap.architecture.allowed.application.CleanProjectionQueryPort;
import dev.caskeleton.bootstrap.architecture.violations.application.BulkWriteWithoutWriteAccessUseCase;
import dev.caskeleton.bootstrap.architecture.violations.application.FixtureRepository;
import dev.caskeleton.bootstrap.architecture.violations.application.GenericLeakQueryPort;
import dev.caskeleton.bootstrap.architecture.violations.application.JakartaValidationApplicationFixture;
import dev.caskeleton.bootstrap.architecture.violations.application.MissingTransactionBoundaryUseCase;
import dev.caskeleton.bootstrap.architecture.violations.application.OutboundWithoutPermissionUseCase;
import dev.caskeleton.bootstrap.architecture.violations.application.RawLeakQueryPort;
import dev.caskeleton.bootstrap.architecture.violations.application.ReadOnlyRepositoryWriteUseCase;
import dev.caskeleton.bootstrap.architecture.violations.domain.AnnotatedPublicNoArgValueObjectFixture;
import dev.caskeleton.bootstrap.architecture.violations.domain.JakartaValidationDomainFixture;
import dev.caskeleton.bootstrap.architecture.violations.domain.entity.FakeDomainEntity;
import dev.caskeleton.bootstrap.architecture.violations.domain.vo.PackagePublicNoArgValueObjectFixture;
import dev.caskeleton.bootstrap.architecture.violations.streaming.JakartaWebSocketEndpointFixture;
import dev.caskeleton.bootstrap.architecture.violations.streaming.SpringWebSocketHandlerFixture;
import org.junit.jupiter.api.Test;
/**
* Negative ("violations-as-data") tests proving each architecture rule actually catches the
* violation it claims to. Each test loads the intentional fixture classes under {@code
* dev.caskeleton.bootstrap.architecture.violations.*} and asserts that the corresponding rule from
* {@link CleanArchitectureTest} reports at least one violation.
*
* <p>Without this layer, a rule that silently no-matches in production (e.g. a predicate
* referencing a package nothing in the codebase happens to populate) would pass vacuously — exactly
* the regression caught in {@code
* raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28}.
*
* <p>Pattern reference: Spring Modulith's {@code modules.detectViolations().getMessages()} on an
* {@code example/ninvalid} fixture package ({@code
* raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data}
* SPRING-MOD-AU-C2).
*/
class ArchitectureViolationFixtureTest {
private static final JavaClasses VIOLATION_CLASSES =
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture.violations");
/** Loaded in isolation to verify D3 rules do NOT over-block StreamingResponseBody. */
private static final JavaClasses ALLOWED_STREAMING_CLASSES =
new ClassFileImporter()
.importPackages("dev.caskeleton.bootstrap.architecture.allowed.streaming");
// Each jakarta.validation fixture is imported in ISOLATION so the two package globs in
// VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY ("..domain.." vs "..application..") are
// verified independently — evaluating both against the shared VIOLATION_CLASSES pool would
// let either fixture satisfy hasViolation(), so a silently broken glob would pass vacuously.
private static final JavaClasses VALIDATION_IN_DOMAIN_FIXTURE_ONLY =
new ClassFileImporter().importClasses(JakartaValidationDomainFixture.class);
private static final JavaClasses VALIDATION_IN_APPLICATION_FIXTURE_ONLY =
new ClassFileImporter().importClasses(JakartaValidationApplicationFixture.class);
// Each WebSocket fixture is imported in ISOLATION so the two package globs in
// NO_WEBSOCKET_HANDLER ("org.springframework.web.socket.." vs "jakarta.websocket..")
// are verified independently. Evaluating both against the shared VIOLATION_CLASSES pool
// would let either fixture satisfy hasViolation() — so a silently broken jakarta (or
// spring) glob would still pass vacuously, the exact failure mode this test layer exists
// to prevent. (importClasses is safe here: both fixtures are annotation-only, so the JVM
// does not resolve the testCompileOnly types at link time.)
private static final JavaClasses SPRING_WEBSOCKET_FIXTURE_ONLY =
new ClassFileImporter().importClasses(SpringWebSocketHandlerFixture.class);
private static final JavaClasses JAKARTA_WEBSOCKET_FIXTURE_ONLY =
new ClassFileImporter().importClasses(JakartaWebSocketEndpointFixture.class);
// feature-application-query-bypass-contract D1: each *QueryPort leak fixture is imported
// in ISOLATION (with FakeDomainEntity so the forbidden type resolves) so the raw-leak and
// generic-leak cases are proven independently. The generic-leak corpus is what proves the
// rule inspects generic type arguments — a raw-return-type check would pass it vacuously.
private static final JavaClasses RAW_LEAK_QUERY_PORT_ONLY =
new ClassFileImporter().importClasses(RawLeakQueryPort.class, FakeDomainEntity.class);
private static final JavaClasses GENERIC_LEAK_QUERY_PORT_ONLY =
new ClassFileImporter().importClasses(GenericLeakQueryPort.class, FakeDomainEntity.class);
/** Over-block guard corpus: a legitimate projection port the D1 rule must NOT flag. */
private static final JavaClasses CLEAN_PROJECTION_QUERY_PORT_ONLY =
new ClassFileImporter().importClasses(CleanProjectionQueryPort.class);
// feature-domain-modeling-guardrails: the @ValueObject rule is an OR of an annotation
// branch and a "..domain.vo.." package branch — each is imported in ISOLATION so a
// silently broken branch cannot pass vacuously via the other one in the shared pool.
private static final JavaClasses VO_ANNOTATION_FIXTURE_ONLY =
new ClassFileImporter().importClasses(AnnotatedPublicNoArgValueObjectFixture.class);
private static final JavaClasses VO_PACKAGE_FIXTURE_ONLY =
new ClassFileImporter().importClasses(PackagePublicNoArgValueObjectFixture.class);
// feature-domain-modeling-guardrails D4/D8: each transport glob in
// DOMAIN_EVENTS_ARE_TRANSPORT_FREE (kafka / spring-http / jax-rs) is proven on its own
// isolated corpus — mirrors the NO_WEBSOCKET_HANDLER isolation. Each fixture lives in its
// own subpackage and is loaded with importPackages, which reads .class bytes directly
// (no JVM classloading), so the testCompileOnly transport types need not resolve at runtime
// — the same mechanism the streaming websocket fixtures rely on.
private static final JavaClasses KAFKA_DOMAIN_EVENT_FIXTURE_ONLY =
new ClassFileImporter()
.importPackages("dev.caskeleton.bootstrap.architecture.violations.domain.event.kafka");
private static final JavaClasses SPRING_HTTP_DOMAIN_EVENT_FIXTURE_ONLY =
new ClassFileImporter()
.importPackages(
"dev.caskeleton.bootstrap.architecture.violations.domain.event.springhttp");
private static final JavaClasses JAXRS_DOMAIN_EVENT_FIXTURE_ONLY =
new ClassFileImporter()
.importPackages("dev.caskeleton.bootstrap.architecture.violations.domain.event.jaxrs");
private static final JavaClasses NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY =
new ClassFileImporter()
.importPackages(
"dev.caskeleton.bootstrap.architecture.violations.domain.event.nonrecord");
// feature-repository-access-permission-contract D12 / D6: each coherence fixture is imported
// in ISOLATION so the two distinct rules are proven independently (a write-call violation must
// not be allowed to satisfy the bulk-access assertion in a shared pool, and vice versa). The
// D12 corpus includes FixtureRepository so the write-method call target resolves.
private static final JavaClasses READ_ONLY_REPOSITORY_WRITE_FIXTURE_ONLY =
new ClassFileImporter()
.importClasses(ReadOnlyRepositoryWriteUseCase.class, FixtureRepository.class);
private static final JavaClasses BULK_WRITE_WITHOUT_WRITE_ACCESS_FIXTURE_ONLY =
new ClassFileImporter().importClasses(BulkWriteWithoutWriteAccessUseCase.class);
private static final JavaClasses MISSING_TRANSACTION_BOUNDARY_FIXTURE_ONLY =
new ClassFileImporter().importClasses(MissingTransactionBoundaryUseCase.class);
// D7: externalOutboundAllowed=false use case that calls OutboxMessagePublishPort
// (bound to OutboxMessagePublishAdapter under ..adapter.outbound..). Imported in isolation; the
// outbound-port set the rule checks against is precomputed from production classes.
private static final JavaClasses OUTBOUND_WITHOUT_PERMISSION_FIXTURE_ONLY =
new ClassFileImporter().importClasses(OutboundWithoutPermissionUseCase.class);
// feature-secrets-config-source-contract D3/D10: the @RefreshScope fixture is loaded via
// importPackages (NOT importClasses) so the JVM never link-resolves the testCompileOnly
// spring-cloud-context annotation type — mirrors the streaming WebSocket isolation rationale.
private static final JavaClasses REFRESH_SCOPE_FIXTURE_ONLY =
new ClassFileImporter()
.importPackages("dev.caskeleton.bootstrap.architecture.violations.secrets");
@Test
void noRefreshScopeAnywhereCatchesRefreshScopeAnnotation() {
EvaluationResult result =
CleanArchitectureTest.NO_REFRESH_SCOPE_ANYWHERE.evaluate(REFRESH_SCOPE_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as("NO_REFRESH_SCOPE_ANYWHERE must catch RefreshScopeUsingFixture (D3/D10)")
.isTrue();
}
@Test
void domainIsPureCatchesSpringDependencyInDomainPackage() {
EvaluationResult result = CleanArchitectureTest.DOMAIN_IS_PURE.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("DOMAIN_IS_PURE must catch SpringDependentDomainFixture")
.isTrue();
}
@Test
void applicationDoesNotUseSpringTransactionalAnnotationCatchesViolation() {
EvaluationResult result =
CleanArchitectureTest.APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION must catch "
+ "TransactionalAnnotatedFixture")
.isTrue();
}
@Test
void applicationDoesNotDependOnApplicationContextCatchesViolation() {
EvaluationResult result =
CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_APPLICATION_CONTEXT.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"APPLICATION_DOES_NOT_DEPEND_ON_APPLICATION_CONTEXT must catch "
+ "ApplicationContextDependentFixture")
.isTrue();
}
@Test
void inboundPortImplementationsEndWithUseCaseCatchesBadlyNamedImpl() {
EvaluationResult result =
CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE must catch " + "BadlyNamedHandler")
.isTrue();
}
@Test
void inboundPortImplementationsDeclareCapabilityCatchesMissingAnnotation() {
EvaluationResult result =
CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY must catch "
+ "MissingCapabilityUseCase")
.isTrue();
}
// The KEYED-idempotency freeze rule + its KeyedIdempotencyUseCase fixture were
// removed when feature-rate-limit-idempotency-contract merged (Idempotency.KEYED is
// now a supported capability), so the corresponding fixture test is gone too.
@Test
void readOnlyUseCasesDoNotCallRepositoryWriteMethodsCatchesReadToWriteUpgrade() {
EvaluationResult result =
CleanArchitectureTest.READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS.evaluate(
READ_ONLY_REPOSITORY_WRITE_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS (D12) must catch "
+ "ReadOnlyRepositoryWriteUseCase calling FixtureRepository.save")
.isTrue();
}
@Test
void bulkWriteCapabilityRequiresWriteRepositoryAccessCatchesReadAccessBulk() {
EvaluationResult result =
CleanArchitectureTest.BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS.evaluate(
BULK_WRITE_WITHOUT_WRITE_ACCESS_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"BULK_WRITE_CAPABILITY_REQUIRES_WRITE_REPOSITORY_ACCESS (D6) must catch "
+ "BulkWriteWithoutWriteAccessUseCase declaring bulkWrite=true with "
+ "repositoryAccess=READ_REPOSITORY")
.isTrue();
}
@Test
void useCaseCapabilityMatchesTransactionPortBoundaryCatchesMissingWriteBoundary() {
EvaluationResult result =
CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY.evaluate(
MISSING_TRANSACTION_BOUNDARY_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY must catch "
+ "MissingTransactionBoundaryUseCase declaring WRITE_REPOSITORY without "
+ "TransactionPort.inWrite")
.isTrue();
}
@Test
void sharedContractScopeRuleCatchesDomainSpecificSharedPackage() {
EvaluationResult result =
CleanArchitectureTest.SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES must catch "
+ "a domain-specific package under ..shared.worklog..")
.isTrue();
}
@Test
void externalOutboundCallsRequireExternalOutboundAllowedCapabilityCatchesUnpermittedCall() {
EvaluationResult result =
CleanArchitectureTest.EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY
.evaluate(OUTBOUND_WITHOUT_PERMISSION_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"EXTERNAL_OUTBOUND_CALLS_REQUIRE_EXTERNAL_OUTBOUND_ALLOWED_CAPABILITY (D7) must "
+ "catch OutboundWithoutPermissionUseCase calling OutboxMessagePublishPort without "
+ "externalOutboundAllowed=true")
.isTrue();
}
@Test
void requestDtosDoNotSilenceUnknownFieldsCatchesClassLevelIgnoreUnknown() {
EvaluationResult result =
CleanArchitectureTest.REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS must catch "
+ "JsonIgnoreUnknownRequestFixture (B1)")
.isTrue();
}
@Test
void noJacksonLaissezFaireSubtypeValidatorCatchesUnsafeDefaultTyping() {
EvaluationResult result =
CleanArchitectureTest.NO_JACKSON_LAISSEZ_FAIRE_SUBTYPE_VALIDATOR.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("NO_JACKSON_LAISSEZ_FAIRE_SUBTYPE_VALIDATOR must catch " + "DefaultTypingFixture (B5)")
.isTrue();
}
@Test
void noJacksonEnableDefaultTypingCallCatchesActivateCall() {
EvaluationResult result =
CleanArchitectureTest.NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"NO_JACKSON_ENABLE_DEFAULT_TYPING_CALL must catch "
+ "DefaultTypingFixture.unsafe() (B5)")
.isTrue();
}
@Test
void noInheritableThreadLocalCatchesInheritableThreadLocalField() {
EvaluationResult result =
CleanArchitectureTest.NO_INHERITABLE_THREAD_LOCAL.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("NO_INHERITABLE_THREAD_LOCAL must catch " + "InheritableThreadLocalFixture (B6)")
.isTrue();
}
@Test
void controllersDoNotReturnDomainOrEntityTypesCatchesDomainReturningFixture() {
EvaluationResult result =
CleanArchitectureTest.CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES must catch "
+ "DomainReturningControllerFixture (판정 기준 Forbidden)")
.isTrue();
}
@Test
void applicationMethodsDoNotAcceptWebDtosCatchesWebDtoAcceptingFixture() {
EvaluationResult result =
CleanArchitectureTest.APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS must catch "
+ "WebDtoAcceptingApplicationFixture (판정 기준 Forbidden + 테스트 계약)")
.isTrue();
}
@Test
void noProblemDetailUsageCatchesProblemDetailFixture() {
EvaluationResult result =
CleanArchitectureTest.NO_PROBLEM_DETAIL_USAGE.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("NO_PROBLEM_DETAIL_USAGE must catch " + "ProblemDetailUsingFixture (D5)")
.isTrue();
}
@Test
void noMergePatchJsonMediaTypeCatchesMergePatchFixture() {
EvaluationResult result =
CleanArchitectureTest.NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING must catch " + "MergePatchJsonFixture (B2)")
.isTrue();
}
@Test
void outboundAdapterMethodReturnsOnlyDomainCatchesRawLeakingFixture() {
EvaluationResult result =
CleanArchitectureTest.OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES.evaluate(
VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES must catch "
+ "RawTypeLeakingAdapterFixture (B7)")
.isTrue();
}
@Test
void controllerRequestMappingsFollowAip122CatchesKebabPathFixture() {
EvaluationResult result =
CleanArchitectureTest.CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122 must catch "
+ "KebabPathControllerFixture (D19)")
.isTrue();
}
@Test
void validCascadeDepthCatchesDeepCascadeFixture() {
EvaluationResult result =
CleanArchitectureTest.VALID_CASCADE_DEPTH_AT_MOST_THREE.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("VALID_CASCADE_DEPTH_AT_MOST_THREE must catch " + "DeepCascadeRequestFixture (B4)")
.isTrue();
}
// ---- feature-business-rule-validation-contract C1/D1 violation fixtures ----
@Test
void validationConstraintsStayAtWebBoundaryCatchesJakartaValidationInDomain() {
// Isolated corpus: proves the "..domain.." glob fires on its own.
EvaluationResult result =
CleanArchitectureTest.VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY.evaluate(
VALIDATION_IN_DOMAIN_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY must catch "
+ "JakartaValidationDomainFixture (C1/D1)")
.isTrue();
}
@Test
void validationConstraintsStayAtWebBoundaryCatchesJakartaValidationInApplication() {
// Isolated corpus: proves the "..application.." glob fires on its own — not vacuously
// via the domain fixture also present in the shared violations tree.
EvaluationResult result =
CleanArchitectureTest.VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY.evaluate(
VALIDATION_IN_APPLICATION_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"VALIDATION_CONSTRAINTS_STAY_AT_WEB_BOUNDARY must catch "
+ "JakartaValidationApplicationFixture (C1/D1)")
.isTrue();
}
// ---- feature-streaming-response-contract D3 violation fixtures ----
@Test
void noSseEmitterCatchesSseEmitterFixture() {
EvaluationResult result = CleanArchitectureTest.NO_SSE_EMITTER.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("NO_SSE_EMITTER must catch SseEmitterUsingFixture (D3)")
.isTrue();
}
@Test
void noResponseBodyEmitterCatchesResponseBodyEmitterFixture() {
EvaluationResult result =
CleanArchitectureTest.NO_RESPONSE_BODY_EMITTER.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("NO_RESPONSE_BODY_EMITTER must catch ResponseBodyEmitterUsingFixture (D3)")
.isTrue();
}
@Test
void noWebsocketHandlerCatchesSpringWebsocketFixture() {
// Isolated corpus: proves the "org.springframework.web.socket.." glob fires on its own.
EvaluationResult result =
CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(SPRING_WEBSOCKET_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as("NO_WEBSOCKET_HANDLER must catch SpringWebSocketHandlerFixture (D3)")
.isTrue();
}
@Test
void noWebsocketHandlerCatchesJakartaWebsocketFixture() {
// Isolated corpus: proves the "jakarta.websocket.." glob fires on its own — not
// vacuously via the spring fixture also present in the shared violations tree.
EvaluationResult result =
CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(JAKARTA_WEBSOCKET_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as("NO_WEBSOCKET_HANDLER must catch JakartaWebSocketEndpointFixture (D3)")
.isTrue();
}
// ---- feature-streaming-response-contract D3 over-block guard (spec Claim #3) ----
@Test
void noSseEmitterDoesNotCatchStreamingResponseBody() {
EvaluationResult result =
CleanArchitectureTest.NO_SSE_EMITTER.evaluate(ALLOWED_STREAMING_CLASSES);
assertThat(result.hasViolation())
.as(
"NO_SSE_EMITTER must NOT catch StreamingResponseBodyAllowedFixture — "
+ "StreamingResponseBody is request-response download, not server-push "
+ "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)")
.isFalse();
}
@Test
void noResponseBodyEmitterDoesNotCatchStreamingResponseBody() {
EvaluationResult result =
CleanArchitectureTest.NO_RESPONSE_BODY_EMITTER.evaluate(ALLOWED_STREAMING_CLASSES);
assertThat(result.hasViolation())
.as(
"NO_RESPONSE_BODY_EMITTER must NOT catch StreamingResponseBodyAllowedFixture — "
+ "StreamingResponseBody FQN is not ResponseBodyEmitter "
+ "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)")
.isFalse();
}
@Test
void noWebsocketHandlerDoesNotCatchStreamingResponseBody() {
EvaluationResult result =
CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(ALLOWED_STREAMING_CLASSES);
assertThat(result.hasViolation())
.as(
"NO_WEBSOCKET_HANDLER must NOT catch StreamingResponseBodyAllowedFixture — "
+ "StreamingResponseBody is not in org.springframework.web.socket.. "
+ "or jakarta.websocket.. packages "
+ "(feature-streaming-response-contract D3 R3 OUT_OF_BRANCH_SCOPE)")
.isFalse();
}
// ---- feature-schema-serialization-contract D3 (BigDecimal precision trap ban) ----
@Test
void noBigdecimalDoubleConstructorCatchesDoubleAndFloatConstructors() {
EvaluationResult result =
CleanArchitectureTest.NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR must catch "
+ "BigDecimalDoubleConstructorFixture (D3 / SBMS-C3)")
.isTrue();
}
// ---- feature-application-query-bypass-contract D1 (read/query port purity) ----
@Test
void queryPortsPurityRuleCatchesRawDomainReturn() {
EvaluationResult result =
CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate(
RAW_LEAK_QUERY_PORT_ONLY);
assertThat(result.hasViolation())
.as(
"QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must catch "
+ "RawLeakQueryPort returning a domain type directly (D1)")
.isTrue();
}
@Test
void queryPortsPurityRuleCatchesGenericDomainReturn() {
// Proves the rule inspects generic type arguments (List<FakeDomainEntity>) — a
// raw-return-type check would pass this vacuously.
EvaluationResult result =
CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate(
GENERIC_LEAK_QUERY_PORT_ONLY);
assertThat(result.hasViolation())
.as(
"QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must catch "
+ "GenericLeakQueryPort leaking a domain type via List<DomainType> (D1)")
.isTrue();
}
@Test
void queryPortsPurityRuleDoesNotFlagCleanProjectionPort() {
EvaluationResult result =
CleanArchitectureTest.QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES.evaluate(
CLEAN_PROJECTION_QUERY_PORT_ONLY);
assertThat(result.hasViolation())
.as(
"QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES must NOT flag "
+ "CleanProjectionQueryPort returning List<String> — no over-block (D1)")
.isFalse();
}
// ---- feature-domain-modeling-guardrails violation fixtures ----
@Test
void domainHasNoLoggerCatchesLoggerInDomainPackage() {
EvaluationResult result =
CleanArchitectureTest.DOMAIN_HAS_NO_LOGGER.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as("DOMAIN_HAS_NO_LOGGER must catch LoggerUsingDomainFixture (D3)")
.isTrue();
}
@Test
void valueObjectRuleCatchesAnnotatedPublicNoArgConstructor() {
// Isolated corpus: proves the @ValueObject annotation branch fires on its own.
EvaluationResult result =
CleanArchitectureTest.VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR.evaluate(
VO_ANNOTATION_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR must catch "
+ "AnnotatedPublicNoArgValueObjectFixture (D5/D6)")
.isTrue();
}
@Test
void valueObjectRuleCatchesPackageConventionPublicNoArgConstructor() {
// Isolated corpus: proves the ..domain.vo.. package branch fires on its own — not
// vacuously via the annotation fixture also present in the shared violations tree.
EvaluationResult result =
CleanArchitectureTest.VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR.evaluate(
VO_PACKAGE_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"VALUE_OBJECTS_HAVE_NO_PUBLIC_NO_ARG_CONSTRUCTOR must catch "
+ "PackagePublicNoArgValueObjectFixture via the ..domain.vo.. branch (D5/D6)")
.isTrue();
}
@Test
void aggregateRootSettersAreNotPublicCatchesPublicSetter() {
EvaluationResult result =
CleanArchitectureTest.AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC.evaluate(VIOLATION_CLASSES);
assertThat(result.hasViolation())
.as(
"AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC must catch "
+ "PublicSetterAggregateFixture (D7)")
.isTrue();
}
@Test
void domainEventsAreRecordsCatchesNonRecordEvent() {
EvaluationResult result =
CleanArchitectureTest.DOMAIN_EVENTS_ARE_RECORDS.evaluate(
NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as("DOMAIN_EVENTS_ARE_RECORDS must catch NonRecordDomainEventFixture (D4/D8)")
.isTrue();
}
@Test
void domainEventsAreRecordsDoesNotFlagARecordEvent() {
// Over-block guard: a @DomainEvent that IS a record must NOT be flagged.
EvaluationResult result =
CleanArchitectureTest.DOMAIN_EVENTS_ARE_RECORDS.evaluate(KAFKA_DOMAIN_EVENT_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as("DOMAIN_EVENTS_ARE_RECORDS must NOT flag KafkaDomainEventFixture — it is a record")
.isFalse();
}
@Test
void domainEventsAreTransportFreeCatchesKafkaType() {
// Isolated corpus: proves the org.apache.kafka.. glob fires on its own.
EvaluationResult result =
CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate(
KAFKA_DOMAIN_EVENT_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch KafkaDomainEventFixture (D4/D8)")
.isTrue();
}
@Test
void domainEventsAreTransportFreeCatchesSpringHttpType() {
// Isolated corpus: proves the org.springframework.http.. glob fires on its own.
EvaluationResult result =
CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate(
SPRING_HTTP_DOMAIN_EVENT_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch SpringHttpDomainEventFixture (D4/D8)")
.isTrue();
}
@Test
void domainEventsAreTransportFreeCatchesJaxRsType() {
// Isolated corpus: proves the jakarta.ws.rs.. glob fires on its own — not vacuously
// via the kafka or spring fixtures in the shared tree.
EvaluationResult result =
CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate(
JAXRS_DOMAIN_EVENT_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as("DOMAIN_EVENTS_ARE_TRANSPORT_FREE must catch JaxRsDomainEventFixture (D4/D8)")
.isTrue();
}
@Test
void domainEventsAreTransportFreeDoesNotFlagACleanEvent() {
// Over-block guard: a transport-free @DomainEvent must NOT be flagged.
EvaluationResult result =
CleanArchitectureTest.DOMAIN_EVENTS_ARE_TRANSPORT_FREE.evaluate(
NON_RECORD_DOMAIN_EVENT_FIXTURE_ONLY);
assertThat(result.hasViolation())
.as(
"DOMAIN_EVENTS_ARE_TRANSPORT_FREE must NOT flag NonRecordDomainEventFixture — "
+ "it references no broker/HTTP type")
.isFalse();
}
}
@@ -0,0 +1,157 @@
package dev.caskeleton.bootstrap.architecture;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static org.assertj.core.api.Assertions.assertThat;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.EvaluationResult;
import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract.SampleFeatureUsingContractFixture;
import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample.SampleFeatureFixture;
import dev.caskeleton.bootstrap.architecture.violations.contractisolation.contract.NonSampleFeatureCoupledContractFixture;
import dev.caskeleton.bootstrap.architecture.violations.contractisolation.features.billing.BillingFeatureFixture;
import org.junit.jupiter.api.Test;
/**
* Contract-test domain-isolation ArchUnit rule (feature-contract-verification-test-suite 테스트 계약
* §1).
*
* <p>Enforces that contract test classes (residing in a {@code ..contract..} package) must NOT
* depend on a non-sample business-domain <em>feature</em> package. The {@code ..features.sample..}
* fixture package is the single allowed exemption.
*
* <p>Uses the <em>manual-importer</em> pattern (plain {@code @Test} + {@code ClassFileImporter})
* rather than {@code @AnalyzeClasses}, because this rule is <em>about</em> test classes. The
* {@code @AnalyzeClasses} suites in this package all use {@code ImportOption.DoNotIncludeTests} and
* therefore cannot see test classes; the manual importer is the only way to load test bytecode.
*
* <h2>PACKAGE_DRIFT adaptation note</h2>
*
* <p>The branch spec's literal glob uses {@code com.example.caskeleton.features.{도메인}}. This
* project's base package is {@code dev.caskeleton}, not {@code com.example.caskeleton}. ArchUnit's
* {@code ..} wildcards make this mismatch irrelevant: the predicates {@code
* resideInAPackage("..features..")} and {@code resideOutsideOfPackage("..features.sample..")} are
* base-package-agnostic and correctly match any package whose qualified name contains {@code
* features} (or excludes {@code features.sample}) regardless of root prefix.
*
* <h2>Predicate composition note</h2>
*
* <p>ArchUnit does not support regex negation like {@code (?!sample)}. The "a {@code ..features..}
* package that is NOT {@code ..features.sample..}" predicate is expressed by composing two
* predicates with {@code .and(...)}: {@code
* resideInAPackage("..features..").and(resideOutsideOfPackage("..features.sample.."))}.
*/
class ContractSuiteIsolationArchTest {
// -------------------------------------------------------------------------
// The isolation rule
// -------------------------------------------------------------------------
/**
* Contract-test domain-isolation rule: a class in a {@code ..contract..} package must not depend
* on a class in a {@code ..features..} package that is NOT {@code ..features.sample..}.
*
* <p>Package-visible field (no {@code @ArchTest}) because this class uses the manual-importer
* pattern; {@code @ArchTest} wiring only fires under {@code @AnalyzeClasses}.
*/
static final ArchRule CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN =
noClasses()
.that()
.resideInAPackage("..contract..")
.should()
.dependOnClassesThat(
resideInAPackage("..features..").and(resideOutsideOfPackage("..features.sample..")))
.as(
"feature-contract-verification-test-suite 테스트 계약 §1: a contract test (..contract..) "
+ "must stay domain-agnostic — it must not depend on a non-sample business-domain "
+ "feature package (..features.. except ..features.sample.. fixtures). "
+ "PACKAGE_DRIFT: base package is dev.caskeleton, not com.example.caskeleton; "
+ ".. wildcards make this base-agnostic.")
.allowEmptyShould(true);
// -------------------------------------------------------------------------
// Positive-control / over-block / clean-check corpora
// Deterministic class-literal imports no package enumeration for controls.
// -------------------------------------------------------------------------
/** Violation corpus: a contract class depending on a non-sample feature type. */
private static final JavaClasses NON_SAMPLE_FEATURE_VIOLATION_CORPUS =
new ClassFileImporter()
.importClasses(NonSampleFeatureCoupledContractFixture.class, BillingFeatureFixture.class);
/** Allowed corpus: a contract class depending only on the sample feature fixture. */
private static final JavaClasses SAMPLE_FEATURE_ALLOWED_CORPUS =
new ClassFileImporter()
.importClasses(SampleFeatureUsingContractFixture.class, SampleFeatureFixture.class);
// -------------------------------------------------------------------------
// Test 1 clean-check: real contract tests pass the isolation rule
// -------------------------------------------------------------------------
@Test
void realContractTestsAreDomainAgnostic() {
// Package-scan corpus: non-vacuity guard ensures the scan is never silently empty.
JavaClasses corpus =
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.contract");
assertThat(corpus.size())
.as(
"contract corpus (dev.caskeleton.bootstrap.contract) must be non-empty — an empty "
+ "scan would make this clean-check pass vacuously "
+ "(feature-contract-verification-test-suite 테스트 계약 §1 non-vacuity guard)")
.isGreaterThan(0);
EvaluationResult result =
CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate(corpus);
assertThat(result.hasViolation())
.as(
"CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN must NOT fire on "
+ "dev.caskeleton.bootstrap.contract — real contract tests must remain "
+ "domain-agnostic (feature-contract-verification-test-suite 테스트 계약 §1)")
.isFalse();
}
// -------------------------------------------------------------------------
// Test 2 positive control: rule fires on non-sample feature coupling
// -------------------------------------------------------------------------
@Test
void isolationRuleFiresOnNonSampleFeatureCoupling() {
// Deterministic importClasses proves the rule is non-vacuous.
EvaluationResult result =
CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate(
NON_SAMPLE_FEATURE_VIOLATION_CORPUS);
assertThat(result.hasViolation())
.as(
"CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN MUST fire on "
+ "NonSampleFeatureCoupledContractFixture (..contract.. → BillingFeatureFixture in "
+ "..features.billing..) — proves the rule is non-vacuous "
+ "(feature-contract-verification-test-suite 테스트 계약 §1 positive control)")
.isTrue();
}
// -------------------------------------------------------------------------
// Test 3 over-block guard: rule does NOT fire on sample feature usage
// -------------------------------------------------------------------------
@Test
void isolationRuleAllowsSampleFeatureFixture() {
// Deterministic importClasses proves the rule does not over-block sample fixtures.
EvaluationResult result =
CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN.evaluate(
SAMPLE_FEATURE_ALLOWED_CORPUS);
assertThat(result.hasViolation())
.as(
"CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN must NOT fire on "
+ "SampleFeatureUsingContractFixture (..contract.. → SampleFeatureFixture in "
+ "..features.sample..) — sample fixture usage is the allowed exemption "
+ "(feature-contract-verification-test-suite 테스트 계약 §1 over-block guard)")
.isFalse();
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.bootstrap.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
/**
* feature-integration-adapter-templates Layer 2 (D3 / §구현 가이드 §3) static isolation + gating guard
* for the optional integration adapters (Kafka / Redis / Slack / Google Email). Owner: this branch.
*
* <p>What this layer statically guarantees, and its documented limit (branch-note L111 / D3 Open
* Risk): ArchUnit can prove (1) the application layer never imports an optional adapter package,
* and (2) every optional-adapter {@code @Bean} is gated by {@code @ConditionalOnProperty} i.e.
* "the adapter candidate class HAS the {@code @ConditionalOnProperty} annotation". Whether the
* adapter is actually <em>active</em> at runtime is a config evaluation ArchUnit cannot reach; that
* runtime guarantee is delegated to Layer 3 ({@code AdapterDisabledException}).
*
* <p>Spring annotation types are referenced by fully-qualified NAME so this test needs no compile
* dependency on spring-context / spring-boot-autoconfigure (they arrive only via adapter-outbound's
* {@code implementation} scope, not transitively here).
*/
@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class)
class DisabledAdapterArchitectureTest {
private static final String[] OPTIONAL_ADAPTER_PACKAGES = {
"..adapter.outbound.messaging.kafka..",
"..adapter.outbound.cache.redis..",
"..adapter.outbound.notification.slack..",
"..adapter.outbound.notification.email.."
};
private static final String BEAN = "org.springframework.context.annotation.Bean";
private static final String CONDITIONAL_ON_PROPERTY =
"org.springframework.boot.autoconfigure.condition.ConditionalOnProperty";
/**
* Layer 2 isolation the application layer must never import an optional adapter package, so a
* disabled adapter's classes can never appear on a use-case path (the static half of the
* disabled-adapter detection; D3).
*/
@ArchTest
static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS =
noClasses()
.that()
.resideInAPackage("..application..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES)
.as(
"D3 APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS: the application layer must "
+ "not import an optional adapter package (Kafka/Redis/Slack/Google Email) — "
+ "the static half of the disabled-adapter detection. Runtime activity is "
+ "delegated to Layer 3 (feature-integration-adapter-templates D3)")
.allowEmptyShould(true);
/**
* Layer 2 gating every {@code @Bean} factory method that lives in an optional adapter package
* must be gated by {@code @ConditionalOnProperty}, so no optional-adapter bean can ship ungated
* (disabled-default could otherwise be bypassed). This is exactly the "adapter candidate
* HAS @ConditionalOnProperty" guarantee from D3 (L111).
*/
@ArchTest
static final ArchRule OPTIONAL_ADAPTER_BEANS_ARE_GATED_BY_CONDITIONAL_ON_PROPERTY =
methods()
.that()
.areAnnotatedWith(BEAN)
.and()
.areDeclaredInClassesThat()
.resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES)
.should()
.beAnnotatedWith(CONDITIONAL_ON_PROPERTY)
.as(
"D3 OPTIONAL_ADAPTER_BEANS_ARE_GATED_BY_CONDITIONAL_ON_PROPERTY: every @Bean in an "
+ "optional adapter package (Kafka/Redis/Slack/Google Email) must declare "
+ "@ConditionalOnProperty(app.<domain>.<adapter>.enabled) — Layer 1 disabled-default "
+ "must not be bypassable by an ungated bean. ArchUnit reaches the annotation "
+ "presence only; runtime activation is Layer 3's job "
+ "(feature-integration-adapter-templates D3, L111)")
.allowEmptyShould(true);
}
@@ -0,0 +1,113 @@
package dev.caskeleton.bootstrap.architecture;
import static org.assertj.core.api.Assertions.assertThat;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Contract dry-run for adding a real domain feature without importing sample-portfolio. */
class DomainFeatureOnboardingContractTest {
private static final String ROOT = "dev.caskeleton.onboarding";
private static final JavaClasses ONBOARDING_CLASSES =
new ClassFileImporter().importPackages(ROOT);
@Test
void readOnlyOnboardingSliceHasMinimumContractAndNoWriteArtifacts() {
assertThat(classNames())
.contains(
ROOT + ".application.query.ListFeatureAggregatesQuery",
ROOT + ".application.query.FeatureAggregateSummary",
ROOT + ".application.port.FeatureAggregateSummaryQueryPort",
ROOT + ".application.usecase.ListFeatureAggregatesUseCase",
ROOT + ".adapter.inbound.web.dto.FeatureAggregateSummaryResponse",
ROOT + ".adapter.inbound.web.mapper.FeatureAggregateResponseMapper",
ROOT + ".adapter.inbound.web.controller.FeatureAggregateController")
.doesNotContain(
ROOT + ".application.command.ListFeatureAggregatesCommand",
ROOT + ".application.port.ListFeatureAggregatesWritePort");
assertThat(
CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_END_WITH_USE_CASE
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.INBOUND_PORT_IMPLEMENTATIONS_DECLARE_CAPABILITY
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.READ_ONLY_USE_CASES_DO_NOT_CALL_REPOSITORY_WRITE_METHODS
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
}
@Test
void writeOnboardingSliceHasMinimumContractAndStaticRulesPass() {
assertThat(classNames())
.contains(
ROOT + ".domain.feature.FeatureAggregate",
ROOT + ".domain.feature.FeatureAggregateCreated",
ROOT + ".domain.feature.FeatureAggregateId",
ROOT + ".domain.feature.FeatureAggregateIdFactory",
ROOT + ".application.command.CreateFeatureAggregateCommand",
ROOT + ".application.port.FeatureAggregateWritePort",
ROOT + ".application.usecase.CreateFeatureAggregateUseCase",
ROOT + ".adapter.outbound.persistence.entity.FeatureAggregateEntity",
ROOT + ".adapter.outbound.persistence.mapper.FeatureAggregateEntityMapper",
ROOT + ".adapter.outbound.persistence.repository.FeatureAggregateRepositoryAdapter",
ROOT + ".adapter.inbound.web.dto.CreateFeatureAggregateRequest",
ROOT + ".adapter.inbound.web.dto.FeatureAggregateResponse",
ROOT + ".adapter.inbound.web.controller.FeatureAggregateController");
assertThat(CleanArchitectureTest.DOMAIN_IS_PURE.evaluate(ONBOARDING_CLASSES).hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.APPLICATION_DOES_NOT_DEPEND_ON_ADAPTERS_OR_TRANSPORT
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.PERSISTENCE_ADAPTER_DOES_NOT_DEPEND_ON_WEB_OR_OUTBOUND_ADAPTERS
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(
CleanArchitectureTest.PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO
.evaluate(ONBOARDING_CLASSES)
.hasViolation())
.isFalse();
assertThat(getClass().getResource("/db/onboarding-migration/V999__feature_aggregate.sql"))
.as("write onboarding dry-run must include a persistence migration artifact")
.isNotNull();
}
private static List<String> classNames() {
return ONBOARDING_CLASSES.stream().map(javaClass -> javaClass.getName()).sorted().toList();
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.bootstrap.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Naming convention enforcement the machine-checkable ({@code [객관]}) subset of {@code
* .agents/plugins/ca-superpowers/rules/code-conventions.md}.
*
* <p>The naming/idiom SSOT is {@code code-conventions.md}. This test enforces only the rules that
* ArchUnit can decide reliably (N6, I5). {@code [판단]} naming rules (N2/N7/N8/...) and rules with
* intentional project exceptions (N3/N4 e.g. the {@code IdFactory} domain port, the {@code
* SpringTransactionPort} canonical impl) stay in {@code ca-quality-reviewer} (doc+review), per the
* code-conventions enforcement matrix.
*/
@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class)
class NamingConventionTest {
// N6 types we own that bind configuration end with *Settings (operational config)
// or *Policy (authz / domain-policy binding). Spring framework *Properties types
// (JacksonProperties, WebMvcProperties, ...) are excluded by the dev.caskeleton scope.
@ArchTest
static final ArchRule CONFIGURATION_PROPERTIES_END_WITH_SETTINGS_OR_POLICY =
classes()
.that()
.areAnnotatedWith(ConfigurationProperties.class)
.and()
.resideInAPackage("dev.caskeleton..")
.should()
.haveSimpleNameEndingWith("Settings")
.orShould()
.haveSimpleNameEndingWith("Policy")
.as(
"code-conventions N6: dev.caskeleton @ConfigurationProperties types end with "
+ "'Settings' (operational config) or 'Policy' (authz/domain policy binding)")
.allowEmptyShould(true);
// I5 every throwable we own is named *Exception (failure category = one meaningful type).
@ArchTest
static final ArchRule THROWABLES_ARE_NAMED_EXCEPTION =
classes()
.that()
.areAssignableTo(Throwable.class)
.and()
.resideInAPackage("dev.caskeleton..")
.should()
.haveSimpleNameEndingWith("Exception")
.as("code-conventions I5: dev.caskeleton throwable types end with 'Exception'")
.allowEmptyShould(true);
}
@@ -0,0 +1,15 @@
package dev.caskeleton.bootstrap.architecture;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.core.importer.Location;
/** Excludes both Gradle's default test output and the sample-off custom test output. */
public final class ProductionClassImportOption implements ImportOption {
private final ImportOption defaultTestExclusion = new ImportOption.DoNotIncludeTests();
@Override
public boolean includes(Location location) {
return defaultTestExclusion.includes(location) && !location.contains("/sampleOffTest/");
}
}
@@ -0,0 +1,224 @@
package dev.caskeleton.bootstrap.architecture;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static org.assertj.core.api.Assertions.assertThat;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.EvaluationResult;
import dev.caskeleton.bootstrap.architecture.allowed.slice.SingleSliceWebMvcFixture;
import dev.caskeleton.bootstrap.architecture.violations.fixtureleak.LeakyProductionConsumerFixture;
import dev.caskeleton.bootstrap.architecture.violations.fixtureleak.fixtures.LeakedTestFixture;
import dev.caskeleton.bootstrap.architecture.violations.slice.MixedSliceAnnotationsFixture;
import dev.caskeleton.bootstrap.taxonomyfixtures.TestcontainersUsingFixture;
import org.junit.jupiter.api.Test;
/**
* Test taxonomy and fixture-contract ArchUnit rules (feature-test-taxonomy-fixture-contract §테스트 계약
* #4 / D3 / D6 / D7).
*
* <p>Uses the <em>manual-importer</em> pattern (plain {@code @Test} + {@code ClassFileImporter})
* rather than {@code @AnalyzeClasses}, because the rules here are <em>about</em> test classes. The
* {@code @AnalyzeClasses} suites in this package all use {@code ImportOption.DoNotIncludeTests} and
* therefore cannot see test classes; the manual importer is the only way to load test bytecode.
*
* <h2>Import strategy determinism over auto-vacuity</h2>
*
* <p>Positive controls and over-block guards import their target by an explicit {@code
* importClasses(SomeFixture.class)} <em>class literal</em>. This is deterministic: it reads a
* specific class file and never enumerates a package against the thread-context classloader. {@code
* importPackages(String)} (used only for the two clean-checks below) was observed to return an
* empty corpus under an inconsistent incremental build which would make a positive control flake
* and, worse, make a clean-check pass <em>vacuously</em>. To defend against the latter, every
* package-scan clean-check first asserts its corpus is non-empty, so a guard can never silently
* stop enforcing.
*/
class TestTaxonomyArchitectureTest {
// -------------------------------------------------------------------------
// Clean-check corpora real contract / architecture test trees (package scan).
// Guarded for non-vacuity in each test so an empty scan fails loudly, never silently.
// -------------------------------------------------------------------------
private static final JavaClasses CONTRACT_TESTS =
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.contract");
private static final JavaClasses ARCHITECTURE_TESTS =
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture");
// -------------------------------------------------------------------------
// Positive-control / over-block corpora deterministic class-literal imports.
// -------------------------------------------------------------------------
/** Holds an org.testcontainers field type — proves the TC-ban rule is non-vacuous. */
private static final JavaClasses TESTCONTAINERS_USING_FIXTURE =
new ClassFileImporter().importClasses(TestcontainersUsingFixture.class);
/**
* @WebMvcTest + @DataJpaTest on one class proves the slice-mixing rule fires.
*/
private static final JavaClasses SLICE_VIOLATION_FIXTURE =
new ClassFileImporter().importClasses(MixedSliceAnnotationsFixture.class);
/**
* @WebMvcTest only over-block guard for the slice-mixing rule.
*/
private static final JavaClasses SLICE_ALLOWED_FIXTURE =
new ClassFileImporter().importClasses(SingleSliceWebMvcFixture.class);
/** A non-fixture class depending on a {@code ..fixtures..} class — proves the leak rule fires. */
private static final JavaClasses FIXTURE_LEAK_VIOLATION_CORPUS =
new ClassFileImporter()
.importClasses(LeakyProductionConsumerFixture.class, LeakedTestFixture.class);
// -------------------------------------------------------------------------
// Task 2 §테스트 계약 #4 / D3: Testcontainers ban for contract + architecture level
// -------------------------------------------------------------------------
/**
* ArchUnit rule: contract-level and architecture-level test classes must not depend on
* Testcontainers. Real-service tests belong to the integration level ({@code
* ..bootstrap.integration..}).
*
* <p>Package-visible field (no {@code @ArchTest}) because this class uses the manual-importer
* pattern; {@code @ArchTest} wiring only fires under {@code @AnalyzeClasses}.
*/
static final ArchRule CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS =
noClasses()
.should()
.dependOnClassesThat()
.resideInAPackage("org.testcontainers..")
.as(
"feature-test-taxonomy-fixture-contract §테스트 계약 #4 / D3: unit·contract·"
+ "architecture level tests must not depend on Testcontainers — real-service "
+ "tests belong to the integration level (..bootstrap.integration..).")
.allowEmptyShould(true);
@Test
void contractLevelTestsHaveNoTestcontainersDependency() {
// Non-vacuity guard: the contract tree must actually be scanned (never silently empty).
assertThat(CONTRACT_TESTS.size())
.as(
"contract corpus (dev.caskeleton.bootstrap.contract) must be non-empty — an empty "
+ "scan would make this clean-check pass vacuously (§테스트 계약 #4 / D3)")
.isGreaterThan(0);
EvaluationResult result =
CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate(CONTRACT_TESTS);
assertThat(result.hasViolation())
.as(
"CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS must NOT fire "
+ "on dev.caskeleton.bootstrap.contract — no Testcontainers usage after "
+ "Task 1 reclassification (§테스트 계약 #4 / D3)")
.isFalse();
}
@Test
void architectureLevelTestsHaveNoTestcontainersDependency() {
assertThat(ARCHITECTURE_TESTS.size())
.as(
"architecture corpus (dev.caskeleton.bootstrap.architecture) must be non-empty — an "
+ "empty scan would make this clean-check pass vacuously (§테스트 계약 #4 / D3)")
.isGreaterThan(0);
EvaluationResult result =
CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate(
ARCHITECTURE_TESTS);
assertThat(result.hasViolation())
.as(
"CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS must NOT fire "
+ "on dev.caskeleton.bootstrap.architecture — no Testcontainers usage "
+ "(§테스트 계약 #4 / D3)")
.isFalse();
}
@Test
void banRuleFiresOnTestcontainersUsage() {
// Positive control / non-vacuity proof: a class with an org.testcontainers field type must
// be flagged. Deterministic importClasses no package enumeration.
EvaluationResult result =
CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS.evaluate(
TESTCONTAINERS_USING_FIXTURE);
assertThat(result.hasViolation())
.as(
"CONTRACT_AND_ARCHITECTURE_TESTS_DO_NOT_DEPEND_ON_TESTCONTAINERS MUST fire on "
+ "TestcontainersUsingFixture (declares a PostgreSQLContainer field) — proves the "
+ "rule is non-vacuous (§테스트 계약 #4 / D3 non-vacuity guard)")
.isTrue();
}
// -------------------------------------------------------------------------
// Task 3 D7 / SB-SLICE-C2: Spring slice annotation mixing ban
// -------------------------------------------------------------------------
/**
* ArchUnit rule: a test class must not carry two Spring slice annotations ({@code @WebMvcTest} +
* {@code @DataJpaTest}). Spring documents mixing slice annotations as not supported
* (SB-SLICE-C2).
*
* <p>Annotations referenced by FQN string consistent with {@link
* DisabledAdapterArchitectureTest}.
*/
static final ArchRule SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS =
noClasses()
.that()
.areAnnotatedWith("org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest")
.should()
.beAnnotatedWith("org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest")
.as(
"feature-test-taxonomy-fixture-contract D7 / SB-SLICE-C2: a test class must not "
+ "combine two Spring slice annotations (@WebMvcTest + @DataJpaTest) — Spring "
+ "documents mixing slice annotations as not supported.")
.allowEmptyShould(true);
@Test
void sliceMixingRuleFiresOnDualAnnotatedClass() {
EvaluationResult result =
SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS.evaluate(SLICE_VIOLATION_FIXTURE);
assertThat(result.hasViolation())
.as(
"SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS MUST fire on "
+ "MixedSliceAnnotationsFixture (@WebMvcTest + @DataJpaTest) "
+ "(D7 / SB-SLICE-C2 positive control)")
.isTrue();
}
@Test
void sliceMixingRuleDoesNotFlagSingleSliceClass() {
EvaluationResult result =
SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS.evaluate(SLICE_ALLOWED_FIXTURE);
assertThat(result.hasViolation())
.as(
"SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS must NOT fire on "
+ "SingleSliceWebMvcFixture (@WebMvcTest only) "
+ "(D7 / SB-SLICE-C2 over-block guard)")
.isFalse();
}
// -------------------------------------------------------------------------
// Task 4 D6: fixture main-classpath leakage guard (meta-test)
// -------------------------------------------------------------------------
@Test
void fixtureLeakRuleFiresOnProductionDependingOnFixture() {
// Evaluates the @ArchTest rule from CleanArchitectureTest against a deterministic
// corpus: LeakyProductionConsumerFixture (outside ..fixtures..) depends on
// LeakedTestFixture (in ..fixtures..). Proves the production-side guard fires.
EvaluationResult result =
CleanArchitectureTest.PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES.evaluate(
FIXTURE_LEAK_VIOLATION_CORPUS);
assertThat(result.hasViolation())
.as(
"PRODUCTION_CODE_DOES_NOT_DEPEND_ON_TEST_FIXTURES MUST fire on "
+ "LeakyProductionConsumerFixture → LeakedTestFixture "
+ "(D6 fixture-leak positive control)")
.isTrue();
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.bootstrap.architecture.allowed.application;
import java.util.List;
/**
* Over-block guard fixture for {@code QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES}
* (feature-application-query-bypass-contract D1).
*
* <p>A clean {@code *QueryPort} in an {@code ..application..} package that returns only an
* application/JDK projection shape ({@code List<String>}). The D1 purity rule must NOT flag it
* proving the rule guards purity without forbidding legitimate projection reads (no over-block).
* Lives under {@code ..allowed.application..} so it is loaded only by the isolated over-block
* corpus, never the production scan.
*/
public interface CleanProjectionQueryPort {
List<String> findTitles();
}
@@ -0,0 +1,8 @@
/**
* "Allowed-as-data" over-block guard fixtures for the feature-application-query-bypass-contract D1
* purity rule. Classes here are LEGITIMATE read/query ports that the {@code
* QUERY_PORTS_DO_NOT_LEAK_DOMAIN_JPA_OR_WEB_TYPES} rule must NOT flag loaded in isolation by
* {@link dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest} to prove the rule
* does not over-block clean projection reads.
*/
package dev.caskeleton.bootstrap.architecture.allowed.application;
@@ -0,0 +1,24 @@
package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract;
import dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample.SampleFeatureFixture;
/**
* Over-block guard fixture: a class in a {@code ..contract..} package that depends only on the
* allowed {@code ..features.sample..} type.
*
* <p>This proves the contract isolation rule does not over-block legitimate sample-fixture usage. A
* contract test depending on {@link SampleFeatureFixture} (in {@code ..features.sample..}) must NOT
* be flagged by {@code CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}.
*
* <p>No {@code @Test} methods; never executed by JUnit. Loaded in bytecode-only form by {@link
* dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link
* com.tngtech.archunit.core.importer.ClassFileImporter#importClasses}.
*
* <p>(feature-contract-verification-test-suite 테스트 계약 §1)
*/
public class SampleFeatureUsingContractFixture {
// The field reference creates the bytecode dependency edge that ArchUnit detects.
@SuppressWarnings("unused")
private final SampleFeatureFixture sampleFeature = new SampleFeatureFixture();
}
@@ -0,0 +1,18 @@
/**
* "Allowed-as-data" over-block guard fixture for the contract isolation rule ({@code
* CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}).
*
* <p>This package simulates a contract test that depends only on the allowed {@code
* ..features.sample..} fixture type. Classes here must NOT be flagged by the isolation rule,
* proving the rule correctly exempts sample-fixture dependencies.
*
* <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded
* in bytecode-only form by {@link
* dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link
* com.tngtech.archunit.core.importer.ClassFileImporter#importClasses} and are excluded from the
* production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only
* source tree).
*
* <p>(feature-contract-verification-test-suite 테스트 계약 §1)
*/
package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.contract;
@@ -0,0 +1,18 @@
package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample;
/**
* Stand-in for the single allowed exemption in the contract isolation rule: a {@code
* ..features.sample..} fixture type that contract tests ARE permitted to depend on.
*
* <p>This class lives under a {@code ..features.sample..} package, making it the over-block guard
* target for the rule ({@code CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}). A
* contract test depending on this class must NOT be flagged by the rule.
*
* <p>No behavior is needed only its package location matters for the bytecode-based ArchUnit
* analysis. No {@code @Test} methods; never executed.
*
* <p>(feature-contract-verification-test-suite 테스트 계약 §1)
*/
public class SampleFeatureFixture {
// Empty only its package location matters for ArchUnit dependency analysis.
}
@@ -0,0 +1,18 @@
/**
* "Allowed-as-data" over-block guard fixture for the contract isolation rule ({@code
* CONTRACT_TESTS_DO_NOT_COUPLE_TO_NON_SAMPLE_FEATURE_DOMAIN}).
*
* <p>This package simulates the single allowed exemption: a {@code ..features.sample..} fixture
* package. Contract tests depending on classes here must NOT be flagged by the isolation rule,
* proving the rule does not over-block legitimate sample-fixture usage.
*
* <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded
* in bytecode-only form by {@link
* dev.caskeleton.bootstrap.architecture.ContractSuiteIsolationArchTest} via {@link
* com.tngtech.archunit.core.importer.ClassFileImporter#importClasses} and are excluded from the
* production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests} (test-only
* source tree).
*
* <p>(feature-contract-verification-test-suite 테스트 계약 §1)
*/
package dev.caskeleton.bootstrap.architecture.allowed.contractisolation.features.sample;
@@ -0,0 +1,22 @@
package dev.caskeleton.bootstrap.architecture.allowed.slice;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
/**
* Allowed fixture: a class annotated with only {@code @WebMvcTest} (single slice).
*
* <p>This fixture is the over-block guard for the ArchUnit rule {@code
* SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS}: a class carrying exactly one Spring slice
* annotation must NOT be flagged. It has NO {@code @Test} methods and is never executed by JUnit
* only its bytecode is read by {@link com.tngtech.archunit.core.importer.ClassFileImporter}.
*
* <p>(feature-test-taxonomy-fixture-contract D7 / SB-SLICE-C2 over-block guard)
*
* <p>Public so the meta-test can reference it via {@code importClasses(...)} from the sibling
* {@code ..architecture} package a deterministic import that does not depend on
* classpath/classloader package enumeration.
*/
@WebMvcTest
public class SingleSliceWebMvcFixture {
// Legitimate single-slice test class. No test methods loaded as bytecode only.
}
@@ -0,0 +1,14 @@
/**
* "Allowed-as-data" over-block guard fixtures for the Spring slice annotation mixing ban ({@code
* SLICE_TESTS_DO_NOT_MIX_TWO_SPRING_SLICE_ANNOTATIONS}).
*
* <p>Classes in this package carry only a single Spring slice annotation and must NOT be flagged by
* the rule. They exist to prove the rule does not over-block legitimate single-slice test classes.
*
* <p>These fixtures have NO {@code @Test} methods and are never executed by JUnit. They are loaded
* in bytecode-only form by {@link
* dev.caskeleton.bootstrap.architecture.TestTaxonomyArchitectureTest} via {@link
* com.tngtech.archunit.core.importer.ClassFileImporter#importPackages} and are excluded from the
* production {@code @AnalyzeClasses} suite via {@code ImportOption.DoNotIncludeTests}.
*/
package dev.caskeleton.bootstrap.architecture.allowed.slice;
@@ -0,0 +1,23 @@
package dev.caskeleton.bootstrap.architecture.allowed.streaming;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
/**
* Over-block guard fixture for the D3 streaming ban rules (feature-streaming-response-contract D3,
* spec Claim #3).
*
* <p>{@link StreamingResponseBody} is explicitly <em>not</em> blocked by D3. It is the large-file
* download / chunked-body mechanism (request-response model, SPRING-ASYNC-C2 "for example, for a
* file download") owned by feature-file-resource-handling-contract D8. Blocking it would break file
* downloads an OUT_OF_BRANCH_SCOPE regression.
*
* <p>This fixture is loaded in isolation against the D3 ArchUnit rules to assert that {@code
* NO_SSE_EMITTER}, {@code NO_RESPONSE_BODY_EMITTER}, and {@code NO_WEBSOCKET_HANDLER} all return
* {@code hasViolation() == false} when only {@code StreamingResponseBody} is referenced.
*/
public class StreamingResponseBodyAllowedFixture {
public StreamingResponseBody allowed() {
return outputStream -> outputStream.write("data".getBytes());
}
}
@@ -0,0 +1,17 @@
/**
* "Allowed-as-data" guard fixtures used by {@link
* dev.caskeleton.bootstrap.architecture.ArchitectureViolationFixtureTest}.
*
* <p>Each class in this package references a type that the D3 streaming ban rules must <em>not</em>
* catch proving that the rules do not over-block. The primary guard is {@link
* dev.caskeleton.bootstrap.architecture.allowed.streaming.StreamingResponseBodyAllowedFixture},
* which references {@code StreamingResponseBody} (large-file download, request-response model,
* owned by feature-file-resource-handling-contract D8 OUT_OF_BRANCH_SCOPE for
* feature-streaming-response-contract D3).
*
* <p>These fixtures are loaded in isolation by {@link
* com.tngtech.archunit.core.importer.ClassFileImporter#importPackages} and asserted to produce
* {@code hasViolation() == false} against the D3 rules. They live under {@code src/test/java/...}
* and are excluded from the production {@code @AnalyzeClasses} suite via {@code DoNotIncludeTests}.
*/
package dev.caskeleton.bootstrap.architecture.allowed.streaming;
@@ -0,0 +1,15 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.controller;
import dev.caskeleton.bootstrap.architecture.violations.domain.entity.FakeDomainEntity;
/**
* Negative fixture for {@code CONTROLLERS_DO_NOT_RETURN_DOMAIN_OR_ENTITY_TYPES}. The class is in
* {@code ..adapter.inbound.web..controller..} (matched by the rule's "declared in" clause) and
* returns a class in {@code ..domain.entity..} exactly the leak the contract forbids.
*/
public class DomainReturningControllerFixture {
public FakeDomainEntity readSomething() {
return new FakeDomainEntity();
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* Negative fixture for {@code CONTROLLER_REQUEST_MAPPINGS_FOLLOW_AIP122} (D19). The class is in
* {@code ..adapter.inbound.web..controller..} and maps kebab-case path segments ({@code
* /work-logs}, {@code /repo-stats}) exactly the naming the AIP-122 contract forbids
* (feature-api-contract-baseline D19).
*/
@RequestMapping("/work-logs")
public class KebabPathControllerFixture {
@GetMapping("/repo-stats")
public String kebabSegment() {
return "violation";
}
}
@@ -0,0 +1,8 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto;
/**
* Lives in {@code ..adapter.inbound.web..dto..} so the {@code
* APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS} ArchUnit rule treats any application method that
* takes it as a parameter as a violation.
*/
public record FakeRequestDto(String value) {}
@@ -0,0 +1,14 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Negative fixture for {@code REQUEST_DTOS_DO_NOT_SILENCE_UNKNOWN_FIELDS} (B1).
*
* <p>Annotated with {@code @JsonIgnoreProperties(ignoreUnknown = true)} at the class level
* exactly the pattern the contract forbids because it cancels the {@code
* FAIL_ON_UNKNOWN_PROPERTIES=true} boundary policy for this DTO alone, masking client-side contract
* drift.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record JsonIgnoreUnknownRequestFixture(String value) {}
@@ -0,0 +1,13 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto;
import org.springframework.web.bind.annotation.PostMapping;
/**
* Negative fixture for {@code NO_MERGE_PATCH_JSON_MEDIA_TYPE_STRING}. Uses the RFC 7396 {@code
* application/merge-patch+json} content type that B2 rejects.
*/
public class MergePatchJsonFixture {
@PostMapping(consumes = "application/merge-patch+json")
public void forbidden() {}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.inbound.web.dto.cascade;
import jakarta.validation.Valid;
/**
* Negative fixture for {@code VALID_CASCADE_DEPTH_AT_MOST_THREE} chained {@code @Valid} fields go
* four levels deep, one over the B4 ceiling.
*/
public class DeepCascadeRequestFixture {
@Valid public Level1 level1;
public static class Level1 {
@Valid public Level2 level2;
}
public static class Level2 {
@Valid public Level3 level3;
}
public static class Level3 {
@Valid public Level4 level4;
}
public static class Level4 {
public String leaf;
}
}
@@ -0,0 +1,7 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound;
/**
* Stands in for a raw external response wire shape. Lives in {@code ..adapter.outbound..} exactly
* where the ACL contract says raw types must not escape from.
*/
public class RawExternalResponseFixture {}
@@ -0,0 +1,12 @@
package dev.caskeleton.bootstrap.architecture.violations.adapter.outbound;
/**
* Negative fixture for {@code OUTBOUND_ADAPTER_METHOD_RETURNS_ONLY_DOMAIN_OR_PRIMITIVES}. Returns a
* class still inside the outbound adapter package the ACL bypass the contract forbids (B7).
*/
public class RawTypeLeakingAdapterFixture {
public RawExternalResponseFixture leakRaw() {
return new RawExternalResponseFixture();
}
}

Some files were not shown because too many files have changed in this diff Show More