CLAUDE.md(英文)给后续 AI 用:文档是约束来源而非描述、构建与测试命令、 architecture-test 三个测试类的不同机制(含源码扫描测试路径写错时静默扫 0 个 文件报绿的坑)、以及版本基线的硬约束。 .gitignore 忽略 .claude / .agents。 Co-Authored-By: Claude Code <noreply@anthropic.com>
9.0 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Rule #0: the docs are the constraint, not a description
Architecture decisions live in ../conti-docs/backend/ (01–13, the single
source of truth). This repo's docs/ is a copy of it, so people don't have to switch repos.
- Edit docs in
../conti-docs/backend/, then sync intodocs/(sync commands — including two cross-repo link fixups — are indocs/README.md). Editing only one side causes drift. - When code and docs disagree, change the code, not the doc. In particular, never loosen a rule in
architecture-testjust to make code pass — loosening the rules is exactly what makes this whole structure worthless. - The few places where the scaffolding deliberately deviates from the docs are commented in the code itself.
Commands
# Everyday check: compile + unit tests + ArchUnit. No Docker needed (~1 min).
./gradlew build -PexcludeIntegration
# Full check: includes Testcontainers / WireMock. Needs a working Docker daemon. Run before an MR.
./gradlew build
# Single test class / single test method
./gradlew :architecture-test:test --tests "*KotlinSyntaxScanTest*"
./gradlew :domains:identity-store:test --tests "*StoreJpaRepositoryTest*"
# Run locally (only MySQL is in Docker; the app runs via Gradle so breakpoints/hot-reload work)
docker compose up -d mysql
SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun # Swagger: http://localhost:8080/swagger-ui.html
# Reset the database
docker compose down -v && docker compose up -d mysql
Tests tagged integration (real MySQL / WireMock) are skipped by -PexcludeIntegration. When you touch a
repository layer or SQL, -PexcludeIntegration does not exercise what you changed — run the full build.
Architecture
Modular monolith: 14 Gradle modules, one deployable jar. Boundaries are enforced physically by the
Gradle dependency graph, and statically by architecture-test.
bootstrap the only module with @SpringBootApplication; the only one producing a jar;
the only one that applies the Boot plugin
platform/platform-* cross-cutting infrastructure (web / security / persistence / observability / integration)
domains/* business domains; identity-store is the only fully implemented vertical slice
integration/* adapters to external systems (f6-adapter / mini-clients)
architecture-test no production code; holds the architecture rules and source-scan tests
Dependency rules (enforced at compile time):
domains/xmust never depend ondomains/y— only ony-contract(a read-only contract module containing no Spring Web / JPA).domains/*may depend onintegration/*andplatform-*.- Among
platform-*, a module may only depend onplatform-web. - Layering:
api → application → domain, withinfrastructuredepended on by both. Thedomainlayer must not reference Spring or JPA at all — its beans are wired explicitly via@Beanininfrastructure/config/. Entities live only in theinfrastructurepackage; theapilayer never sees them.
Adding a domain requires wiring 4 places. Miss one and the symptom is subtle (it compiles and boots, but Flyway doesn't run or the boundary is silently unguarded):
settings.gradle: add theinclude, and write the module'sbuild.gradle.bootstrap/build.gradle: addimplementation project(...), or it never lands in the jar.DomainFlywayConfig.DOMAIN_SCHEMAS: add the schema name, createdb/migration/<schema>/V1__init.sql(a Flyway location must not be an empty dir), and add theCREATE DATABASEtodocker/mysql/init/01-create-databases.sql.ArchitectureRulesTest.domains: add the package name;NativeQueryScanTest.ownerByModule: add"xxx" to "<schema>".
Package name is the directory name with hyphens stripped (identity-store → identitystore); the sole
exception is bff-orchestration → bff.
architecture-test: three test classes, three mechanisms
Know what's in here before changing production code, or you'll hit a red build and not know why:
| Test class | Mechanism | What it catches |
|---|---|---|
ArchitectureRulesTest |
ArchUnit over bytecode | layer directions, Entity placement, field injection, java.util.Date, util-class naming, etc. |
NativeQueryScanTest |
reads source text | a native SQL string naming another domain's schema — schema names are string literals, invisible in bytecode |
KotlinSyntaxScanTest |
reads source text | !! / lateinit / suspend / kotlinx.coroutines / RxJava / Reactor / data class *Entity |
Two traps in the source-scan tests (you only see them by reading the code):
- They locate sources with relative paths (
Path.of("../domains")), relying on Gradle running them with the module dir as working directory. If you add a new top-level source root, you must add it toKotlinSyntaxScanTest.productionSources()— otherwise the new code is never checked. productionSources()containsfilter { Files.exists(it) }: if the path is wrong it silently scans zero files and reports green. When writing a test like this, confirm it actually reads files by planting a deliberate violation and checking that it goes red — don't trust a green alone.
Kotlin coding constraints (see 13-kotlin-style.md)
!! / lateinit / coroutines / RxJava / Reactor / data class entities / any
XxxUtils|Util|Helper|Common class are build gates — violating them fails test and therefore
build. They are not suggestions.
Two things that are deliberately not automated:
companion objectrestricted to legitimate uses — left to review (all 14 existing uses are legitimate; a hard rule would only cause false positives).- Entities must be proxyable by Hibernate — currently NOT enforced (known gap): the root
build.gradledoes not applykotlin("plugin.jpa"), so entities are final, Hibernate cannot proxy them, andfetch = LAZYsilently degrades to eager. This hasn't blown up only because there are currently zero lazy associations. Read the "已知缺口(2026-09)" (known gap) section of13-kotlin-style.mdbefore adding anything like@ManyToOne(fetch = LAZY).
Hard version constraints
| Component | Version | Constraint |
|---|---|---|
| Gradle | 9.5.0 (wrapper) |
Two-sided: the Boot 4.1 plugin needs 8.14+ or 9.x, while KGP 2.4.10's fully-supported ceiling is 9.5.0. Bumping either Boot or Kotlin means recomputing the intersection. |
| Kotlin | 2.4.10 |
|
| Java | 21 (pinned via toolchain) |
|
| Spring Boot / Cloud | 4.1.0 / 2025.1.2 |
- Version alignment always goes through Gradle's native
platform()BOM;io.spring.dependency-managementis deliberately not used (that plugin historically breaks on every Gradle major). - Gradle 9 no longer bundles the JUnit Platform launcher — deleting
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'from the rootbuild.gradlemakes everytesttask fail outright. - Boot 4 moved packages:
@DataJpaTest→org.springframework.boot.data.jpa.test.autoconfigure,@AutoConfigureTestDatabase→org.springframework.boot.jdbc.test.autoconfigure(both needspring-boot-starter-data-jpa-test);spring-boot-starter-aopis nowspring-boot-starter-aspectj.
Database conventions
- One domain, one database (in MySQL, schema ≡ database), so
DomainFlywayConfigstarts one Flyway instance per entry inDOMAIN_SCHEMAS. - An already-executed Flyway script must never be edited (checksum mismatch → startup fails with
Validate failed; this applies to the dev environment too). To change something, add a higher-versioned script. - Every migration must be forward-compatible:
maxUnavailable: 0means old and new pods share one database during a rolling update, so dropping or altering a column requires expand-contract across two releases. - Store-scoped queries must use
StoreContextHolder.currentStoreId(), never astoreIdrequest parameter — that parameter is the standard entry point for an authorization-bypass query. - Seed data (
LocalSeedDataConfig) exists only under thelocalprofile and must never be written into a Flyway script.
Deployment and misc
localon your own machine (Gradle);devon internal k3s (./scripts/deploy-dev.sh <sha>, connect VPN first);uat/prodon Azure AKS (protected tag → manual Promote in GitLab).- Images don't compile code: the
Dockerfileconsumes the jar produced by the CIvalidatestage, so the bytes deployed are the bytes that were tested. - Real secrets are in no file in this repo;
k8s/secret-uat.yamlholds only__injected_by_pipeline__placeholders. - The
lint(ktlint/detekt) and coverage jobs are currently left commented out in.gitlab-ci.yml— the plugins aren't wired up, so enabling them would go red immediately..editorconfigalready reserves theij_kotlin_*section.