Files
Guangfei.ZhaoandClaude Code b2903d6eeb docs: add backend/13-kotlin-style.md and index it
Kotlin syntax & coding constraints for the backend service: treat Kotlin as Java
syntax sugar, not as a new paradigm. Bans !! / lateinit / coroutines / RxJava /
Reactor, data-class entities, and XxxUtils|Util|Helper|Common classes — each rule
states the concrete failure it prevents.

Also records the Gradle two-sided version constraint in 01-project-structure.md:
the Boot 4.1 plugin needs 8.14+ or 9.x while KGP 2.4.10 tops out at 9.5.0, so
bumping either side means recomputing the intersection.

Skeleton follows the existing convention (## 决策 first, then 使用规则, topic
sections, ending with a 为什么不选另一个方案 comparison table).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 16:09:52 +08:00

14 KiB
Raw Permalink Blame History

13. Kotlin 语法与编码约束

决策

Kotlin 在本项目里只当 Java 的语法糖用,不当新范式用。

判断一个新特性该不该用,只问一句话:它对 Java 是缩短,还是替换?

特性 性质 用不用
空安全(? / ?: / takeIf)、val/var、属性、data class、扩展函数、顶层函数 把 Java 写得更短,心智模型没变 ✅ 用
when 表达式、sealed 层次 把 Java 的 switch + 继承写得更短且更严(分支穷尽性由编译器保证) ✅ 用
协程 / suspend / Flow、RxJava、Reactor 换一套并发心智模型,且与本项目的阻塞式 JPA + ThreadLocal 上下文直接冲突 ❌ 不用
companion object 当 static 工具袋、object XxxUtils 换一套"万物皆对象、没有 static"的世界观,收益是零 ❌ 不用
DSL builder、Monad Transformer、inline + reified 造框架 换一套抽象方式,在 CRUD 业务里只换来调试成本 ❌ 不用

第二条准入标准:本文档里每条规则都要能指出它挡住的具体故障。 指不出来的不写——写下来也不会有人遵守,只会在 review 时引发无穷争论。下面 DON'T 清单里每一条都带了「挡住什么」,那就是它的准入证明。

这条标准本身是 02-layering.md 里那次论证的推广。那份文档解释了为什么规则不是更严的"Entity 永不跨出 infrastructure"——因为更严的版本会逼出无意义的样板代码,却换不来任何隔离收益。语法规则同理:不能减少实际复杂度的规则,只是把复杂度从代码挪进了文档。

使用规则

DO

  • data class:DTO / Request / Response / Value Object,以及 domain 层的领域模型(如 WebviewTicket)。纯数据、无生命周期、无懒加载。
  • sealed interface / sealed class:有限且封闭的状态建模,when 上不用写 else,新增状态时编译期报错。状态不带附加数据时仍用 enum(现有 ErrorCode、TicketStatus、UserStatus 都是 enum,这是对的);当每个状态要携带不同的字段时再换 sealed。
  • val 优先,var 只留给 JPA Entity 的持久化字段——而 Entity 只存在于 infrastructure 包(02-layering.md)。
  • 无状态逻辑用顶层函数,不要为了"整洁"造一个 @Component 的壳。全库目前只有 13 个顶层声明,说明这条没有被滥用:绝大多数逻辑都有它该归属的类。
  • 扩展函数用于同层内的轻量转换:WebviewTicketEntity.toDomain()、UserEntity.toResponse()。跨层转换不用扩展函数,放 application/mapper/(06-api-design.md)。

DON'T

❌ 生产代码禁止 !!

挡住什么:!! 抛的是 NullPointerException,消息里只有 null 两个字——没有类名、没有字段名、没有业务上下文。线上日志里出现一个光秃秃的 NPE,定位成本远高于写一行 ?: throw。

// ❌ 违反不变量时,只能得到一个没有上下文的 NPE
accessTokenIssuer.issue(user.id!!, storeId, roles)

// ✅ 不变量有名字,报错带类名
accessTokenIssuer.issue(user.requiredId, storeId, roles)

根因不在语法,在数据来源。 应用层拿到的 Entity 只有两种状态:new 出来的(id 必然是 null)和从 repository 读出来 / save 过的(id 一定有)。上面那个 !! 想表达的正是后者这条不变量——那就把它写成类型:platform-persistence 的 PersistedEntity.requiredId。

所以本项目的修法不是"把 !! 逐处换成 ?: throw"(那只是把同样的假设抄了 7 遍,还没解决问题),而是让不变量有一个名字。

src/test 里的 !! 不禁止:测试里 null 是立即可见的失败信号,再包一层 ?: throw 只是噪音。

❌ 生产代码禁止 lateinit var

挡住什么:lateinit 是"我保证在使用前一定赋值"的口头承诺,编译器从此不再管你。它和 !! 是同一个洞的两半——!! 是读的时候不安全,lateinit 是写的时候不检查。

字段注入时代它还有存在理由;本项目已经禁止字段注入(ArchitectureRulesTest 里有规则),依赖一律走构造器参数,lateinit 就没有场景了。需要延迟初始化就用 by lazy(只读且线程安全)。

❌ 禁止引入协程 / suspend / Flow / RxJava / Reactor

挡住什么:见下方「为什么不用协程,而用虚拟线程」。结论是同步到底;需要并发就用虚拟线程,不引入任何异步框架。

2026-09 落地时全库 0 处违规——这是条预防性规则,不是清理性规则。它的价值在半年后有人提出"我们用协程改造一下"时,能直接翻到下面那一节,而不是重新辩论一遍。

❌ 禁止 XxxUtils / XxxUtil / XxxHelper / XxxCommon 类

挡住什么:这类类名是"我懒得想这东西属于谁"的标记。它们一旦出现就会变成什么都往里塞的垃圾桶,并且彼此依赖,最后没人说得清 CommonUtils 里到底有什么、改一行会炸哪里。

替代:无状态逻辑用顶层函数,让文件名表达归属(ErrorCode.kt 里的 object ErrorCode);有状态的用 @Component 由容器管。

XxxManager 不在这条规则里:Spring 自身就有大量 *Manager 类名,一刀切会误伤,交给 review 判断。

❌ JPA Entity 禁止 data class,且必须能被 Hibernate 代理

挡住什么,三条都是真事故:

  1. data class 的 equals/hashCode 基于可变的 id。实体放进 Set/Map 后再 flush 拿到 id,哈希值就变了,那个元素再也找不回来。
  2. 生成的 toString 会打印所有字段——只要有一个懒加载关联,就是一次意料之外的查询,甚至 LazyInitializationException。
  3. copy() 造出的是游离态新对象,save() 会被当成新增而不是更新。

Entity 一律写成普通 class + var 持久化字段(现有 6 个 Entity 都是这么写的,保持住)。

并且 Entity 必须是 open 的,否则 Hibernate 造不出代理,fetch = LAZY 会静默退化成每次立即查库——测试里看不出来,只有 SQL 条数的差别。

⚠️ 已知缺口(2026-09):根 build.gradle 只 apply 了 org.jetbrains.kotlin.plugin.spring,没有 plugin.jpa,所以现有 Entity 都是 final 的。当前不出问题,是因为全库一个懒加载关联都没有(0 处 @ManyToOne / @OneToMany),Hibernate 压根不需要造代理。

哪天有人加第一个 @ManyToOne(fetch = LAZY),上面第 3 条就会静默生效。修法是 apply org.jetbrains.kotlin.plugin.jpa(它给 @Entity / @MappedSuperclass 补 open 和 no-arg 构造器,见 03-persistence.md),改完必须跑一遍打了 @Tag("integration") 的仓储测试——那是唯一能发现代理行为变化的地方。

为什么规则不是"禁止 companion object"

全库 14 处 companion object,逐一看过,没有一处是该删的:

位置 用途 改成顶层会怎样
F6Exceptions.kt 的两个异常类 private companion object { F6_LOG } 拿类级 logger category 顶层 private val 会变成文件级 category(F6ExceptionsKt),日志分类直接退化
Tile.kt 的 ok() / degraded() 类的命名工厂 顶层只能叫 tileOk(),丢掉类型归属
AuthAppService.kt private 常量(失败次数上限、锁定时长、假哈希) 常量失去"属于这个服务"的语义
ApiResult.kt / ErrorCode.kt 等 同上的构造入口与常量 同上

所以这条规则被砍掉,理由有两条:

  1. 它没有一个能自动检查的形态。 "禁止 companion object" 和 "禁止把 companion object 当 static 工具袋用" 在字节码上长得一模一样,只能靠人判断。而一条只能靠人判断、且现有 14 处全是合法用法的规则,实际效果是让每次 review 多花五分钟争论它算不算例外。
  2. 它本来是性能论据,但性能论据是错的。 Kotlin 用 companion object 替代 static 是"概念一致性"的取舍,不是性能取舍:companion 是每个类装载器一个单例,const val 还会被编译期内联,不存在 per-call 分配。拿性能去反驳一个概念性决定,等于认错了对方的动机,打不到点上。

真正该禁的是"垃圾桶类",那件事已经由上面那条 XxxUtils 规则接管了。companion object 的合法用途就三种:类级 logger、类的命名工厂、类私有常量。超出这三种,review 时问一句"这个方法为什么不属于某个类"就够了。

为什么不用协程,而用虚拟线程

结论:同步到底;需要并发就用虚拟线程。

先说一个容易被忽略的前提:本项目的请求上下文全部挂在 ThreadLocal 上。

上下文 承载方式
门店上下文 StoreContextHolder / StoreContextFilter
事务 @Transactional(连接与事务绑定在 ThreadLocal 上)
认证信息 Spring Security 的 SecurityContextHolder
审计 AuditContext
链路追踪 Micrometer Tracing 的 current span

协程一旦在 Dispatchers.IO 之类的地方发生线程切换,上面这些 ThreadLocal 会静默丢失——不报错,只是门店过滤条件没了、事务不在同一个连接上、审计记不到人。这不是"风格不统一",这是越权和数据错乱级别的 bug,而且极难复现。

所以不用协程的第一理由是正确性,不是"协程是银弹"这类口感判断。用口感当理由,半年后有人拿"协程写起来更优雅"就能把它推翻。

那并发需求怎么办?——虚拟线程,本项目 toolchain 已经是 Java 21(01-project-structure.md):

做法 吞吐 ThreadLocal / @Transactional 调试
平台线程 + CompletableFuture 受线程池大小限制 正常 栈完整
虚拟线程 + CompletableFuture 阻塞时自动让出载体线程,接近协程的吞吐 照旧正常 栈完整
协程 / suspend 高 静默丢失 栈不可读,异常要靠 CoroutineExceptionHandler
WebFlux + Reactor 高 需要额外桥接,且 JPA 是阻塞的 栈不可读,需 Reactor Debug Agent

workbench 那个跨 F6 + Mini 的并行 fan-out(11-cross-domain-collaboration.md)就是目标场景:把 WorkbenchExecutorConfig 的线程池换成 Executors.newVirtualThreadPerTaskExecutor(),业务代码一行不用改,拿到接近响应式的吞吐,而 Hibernate、事务、门店上下文全部照旧工作。

为什么也不引 RxJava:RxJava 正是上面那张"糖 vs 范式"表里被否掉的那一类——操作符爆炸、栈不可读、学习曲线陡。而且 Spring 生态的响应式是 Reactor(WebFlux),引 RxJava 等于在阻塞栈里塞进第二套并发模型,两套都要维护。真要异步流,答案也是虚拟线程,不是再引一个框架。

检查方式

规则 强制手段 状态
禁 !! / lateinit / suspend / kotlinx.coroutines / RxJava / Reactor KotlinSyntaxScanTest(源码扫描) ✅ 自动
Entity 禁 data class KotlinSyntaxScanTest ✅ 自动
禁 XxxUtils / Util / Helper / Common 类 ArchitectureRulesTest ✅ 自动
禁字段注入、禁 java.util.Date / Calendar ArchitectureRulesTest(既有规则) ✅ 自动
companion object 只用于三种合法用途 无 review 判断
Entity 必须 open 无 ⚠️ 待 apply plugin.jpa(见上文已知缺口)

为什么 !! 用源码扫描而不是 ArchUnit:!! 编译成 Intrinsics.checkNotNull(...) 调用,字节码上它和其他 null 检查混在一起,ArchUnit 分不出来;而源码里 !! 就是一个确定的 token。

这是 10-testing.md 那条思路的第二次应用——"编译器和 ArchUnit 拦不住的东西,加一条源码扫描作为第二道防线"。第一次是 NativeQueryScanTest(库名是字符串,字节码里看不见)。

后续:.editorconfig 里已经预留了 ij_kotlin_* 段,说明 ktlint 迟早要接;接的时候 !! / lateinit 这些可以改用 detekt 的 UnsafeCallOnNullableType 等现成规则。在那之前,源码扫描是零依赖、零构建改动的做法,先把它换成可执行的。

为什么不选另一个方案

方案 否决理由
全面拥抱协程 + Spring WebFlux 本项目是阻塞式 JPA + MySQL(03-persistence.md),WebFlux 的收益要整条链路非阻塞才拿得到;混在阻塞 JDBC 上只会把线程从 Tomcat 挪到 Reactor 的调度器里,同时丢掉 ThreadLocal 上下文。虚拟线程拿到了同样的吞吐,代价是零。
用 RxJava 做异步流 见上一节结尾。而且 Spring 生态的响应式是 Reactor,引 RxJava 是引入第二套并发模型。
禁止所有 companion object 见「为什么规则不是禁止 companion object」。14 处合法用法会被误伤,且规则无法自动检查。
把编码规范写成纯文档,靠 review 执行 这套文档被定义成"代码的约束来源",但一条不能被机器检查的规范,半年后就会和代码脱节。能上机器检查的一律上机器检查——这不是不信任团队,是省下 review 的注意力去做机器做不了的事。
现在就接 detekt + ktlint 全套 值得做,但不是这次。先零依赖地把规则变成可执行测试,等格式化类规则积累够了再一次性接工具链——避免为了 6 条规则先引一套构建插件。