KotlinConf Recap: Lessons from Verifone’s Architect on Kotlin in Mission-Critical Payments
TL;DR · AI 摘要
Title: KotlinConf Recap: Lessons from Verifone's Architect on Kotlin in Mission-Critical Payments - The JetBrains Blog U...
核心要点
- 主题聚焦:KotlinConf Recap: Lessons from Verifone’s Archit
- 来源:The JetBrains Blog,建议结合原文判断细节。
- AI 分析暂不可用,本条为保底评分与摘要。
标题:KotlinConf回顾:来自Verifone架构师关于Kotlin在关键支付系统中应用的经验教训 - JetBrains博客
URL来源:https://blog.jetbrains.com/kotlin/2026/10/kotlinconf-recap-lessons-from-verifone-s-architect-on-kotlin-in-mission-critical-payments/
Markdown内容:
由JetBrains开发的简洁多平台语言
KotlinConf回顾:来自Verifone架构师关于Kotlin在关键支付系统中应用的经验教训
2026年10月14日
Yuri Geronimus四岁的儿子曾问他工作是什么。Yuri告诉他,他写书:写描述自己想要改变的物理世界的书。他的儿子并没有完全理解。
大多数工程师也不会完全理解:如果你理解应用程序代码中的词语,你就能构建良好的系统;如果不理解,你构建的将是漏洞。
这正是Yuri Geronimus在KotlinConf 2026上的演讲“Kotlin作为安全网:金融行业中的类型驱动可靠性”的核心思想。Yuri是Verifone的架构师,他在EMEA地区支付终端、网关以及连接银行和监管机构的系统中工作。他的方法用一句话概括:用监管机构或银行卡网络规范中的精确措辞编写代码,并让编译器强制执行。不是通过纪律,而是通过编译器。
观看KotlinConf 2026演讲
观看Yuri Geronimus的KotlinConf 2026演讲,了解Kotlin如何帮助构建可靠的支付系统。

企业级Kotlin采用的遗留系统现代化策略与商业案例手册
**终端背后的世界**
在介绍模式之前,Yuri设定了场景。一个“支付”不是一个应用程序。它包括移动销售应用、销售点应用、运行在封闭操作系统上的支付终端、支付网关、终端管理系统、商户平台,以及底层的处理器、银行和监管机构。每个国家都有数千页的规范和约1000个场景必须满足,才能进入生产环境。
其中一些终端根据法规必须离线运行长达40天,因为在它们运营的国家,支付终端与加油站或药店一样,属于关键基础设施。支付团队还必须满足来自不同监管机构的矛盾规则,这些规则管理着相同的共享基础设施:一个国家要求某种行为,另一个国家禁止它,而这两个规则必须在同一个代码库中同时满足。这就是类型系统必须支撑的环境。这不是演示环境。而是全球范围数十年来的生产环境,而非一年。
- 一个错误的字段导致交易被撤销,他人资金被错误转入了错误账户。
- 一个必填字段被设置为可空字段"以防万一"。当固件升级停止发送该字段时,代码回退到默认值,导致部分终端设备停止处理支付。
- 卡号意外出现在日志行和堆栈跟踪中:这构成了披露义务,也带来了真正的合规问题。
- 一笔交易在已被撤销后又被退款,导致商户被重复扣款。
- 授权请求刚好在超时触发时成功执行,资金被转为盗窃
这些问题都可以追溯到同一个根本原因:在某个地方,代码与规范对"单词"的定义产生了分歧,而这种分歧在进入生产环境前从未被强制暴露出来。
**方法:先编写规范,再让编译器检查你的工作**
尤里将这种方法描述为一组模式而非框架。共有十种模式,它们都做着相同的工作:使非法状态无法被构造,而不仅仅是捕获后续出现的无效状态。
**1. 用值类表示规范名词**
欺诈和风险模型中包含数十个形状相似的ID:商户ID、终端ID、收单行ID等,其中一些由于监管要求在格式上完全相同。作为普通字符串时,重构或复制粘贴操作可能导致两个ID被交换,交易会静默地路由到错误的收单行。尤里的解决方案是为每个ID创建值类。
@JvmInline value class Stan(val value: String) @JvmInline value class TerminalId(val value: String) @JvmInline value class AmountMinor(val value: Long)
data class AuthRequest(val stan: Stan, val terminalId: TerminalId, val amount: AmountMinor) AuthRequest(terminalId, stan, amount) 现在交换参数,代码将无法编译。曾经能进入生产环境的错误现在变成了工程师本地构建时的错误。
**2. 验证工厂**
每个外部字段,无论是来自银行API还是终端固件,都以未类型化的字符串形式到达,可能与规范假设的含义不一致。尤里的模式会在边界处立即将其解析为密封结果,而不是信任后续处理。
sealed interface ParsedStan { data class Valid(val stan: Stan) : ParsedStan sealed interface Invalid : ParsedStan { val rawStan: RawStan data class WrongLength(override val rawStan: RawStan) : Invalid data class NotNumeric(override val rawStan: RawStan) : Invalid } }
fun of(rawStan: RawStan): ParsedStan = when { rawStan.value.length != 6 -> ParsedStan.WrongLength(rawStan) !rawStan.value.all(Char::isDigit) -> ParsedStan.NotNumeric(rawStan) else -> ParsedStan.Valid(Stan(rawStan.value)) }
fun authorize(rawStan: RawStan): AuthResponse = when (val parsed = ParsedStan.of(rawStan)) { is ParsedStan.Valid -> gateway.authorize(parsed.stan) // 正常流程 is ParsedStan.Invalid -> reject(parsed) // 结构化拒绝 } // 没有else,没有try/catch,没有需要解包的Result<T> —— 穷尽性检查被强制执行 尤里认为这种方法是非常好的实践:它不会抛出异常;它有助于避免使用Result<T>的非最佳方案,后者会隐藏业务领域,并防止协程中的runCatching陷阱
**3. 边界处的非空类型**
许多情况下,必填字段会被标记为可空类型“以防万一”,这需要处理大量静默回退逻辑,而这些逻辑往往掩盖了真正的故障原因。Yuri建议利用Kotlin的空安全机制,在一个位置明确失败:
data class KernelResult(
val tvr: TerminalVerificationResults, // ← 不是Optional,也不是可空类型
)
fun fromFirmware(raw: FirmwareResponse) = KernelResult(
tvr = requireNotNull(raw.tvr) { "TVR null - firmware contract violated" }
)
// 在边界处只出现一次明确失败。下游绝不允许静默回退这就是解决终端集群故障的方法:原本被标记为可空类型“以防万一”的字段,从此不再有静默默认值。它会在唯一可预测的解析位置明确失败,该位置可以被监控,而不是在下游无人关注的地方静默失败。
**4. 默认情况下对敏感数据进行掩码处理**
卡号以明文字符串形式出现在日志和堆栈跟踪中,这构成了潜在的信息泄露风险。Yuri通过将值封装在带有私有构造函数的类中并重写toString()方法,使安全行为成为默认选项。
@JvmInline
value class Pan private constructor(private val value: String) {
override fun toString() = "PAN[****]" // ← 始终安全
fun masked() = /* 按规范进行掩码处理 */
fun toNetwork() = value // 明确设计,便于搜索
}
logger.info("Processing card $card") // → "PAN[****]". 默认安全此时意外的log.info(“processing $pan”)会打印出四个掩码后的数字。要获取真实值(唯一真正需要与网络通信的值),必须进行显式的、可审计的调用。这条路径不再有任何隐藏之处。Yuri的小注:在真实系统中,这种设计隐藏得更深。
**5. 为每个状态创建独立的类,而不仅仅是名称**
已被退款的交易不应该能够再次调用退款操作,但单个可变Transaction类依赖于某个字段的状态值,需要在所有地方都记得检查状态。Yuri的进阶方案为每个状态创建独立的数据类,使方法无法从错误的数据类修改交易状态;这确保了不应发生的转换(例如对已撤销交易进行退款)会被编译器阻止。
sealed interface TransactionStatus {
data class Captured(val amount: AmountMinor) : TransactionStatus
data class FullyRefunded(val at: Instant) : TransactionStatus
data class Reversed(val reason: ReversalReason) : TransactionStatus
}
fun TransactionStatus.fullRefund(refundReason: RefundReason) = when (this) {
is Captured. -> Ok(/* ... */)
is FullyRefunded, is Revesed -> Err("not refundable from $this")
} // 穷尽性检查。编译器拒绝遗漏任何情况**6. 不可变性与命名转换**
这延续了之前的模式。将业务数据类设为不可变,并为每个转换操作命名。名称来自规范中的动词。任何不应被调用的方法直接不存在:
@ConsistentCopyVisibility // Kotlin 2.0.20+
data class PurchasedAuthorization private constructor( /* ... */ ): Authorization {
fun fullRefund(refundReason: RefundReason): FullRefundOutcome = /* ... */
}sealed class FullRefundOutcome { data class Succeeded(val refundAuthorization: RefundAuthorization) : FullRefundOutcome // error data classes } Yuri 将这种模式称为他最喜欢的模式,因为它会影响工程师如何阅读规范。这改变了工程师对业务领域的思考方式和表达方式。
**7. 删除 else 分支**
如果规范明确列出所有可能情况且从不使用“否则”表述,那么代码中的 else 分支就引入了规范未描述的新情况。他的团队最初只处理了全额退款。当部分退款成为真实场景时,旧的 else 分支会默默地吞没这种情况:编译通过、测试通过,但实际生产中是错误的。删除 else 分支后,新增的情况会直接导致编译错误,并指向所有需要更新的位置。
fun authorize(request: AuthorizationRequest): Authorization = when (request) { is PurchaseRequest -> /* ... */ is FullRefundRequest -> /* ... */ // ← 没有 else。新增状态会在此处破坏编译 } 支付业务在某些国家每月甚至每季度都会发生持续变化,而在 Yuri 的规模下,这种变化在 EMEA 区域会每天发生。一个详尽的 when 表达式会将所有变化转化为需要修改的具体列表,而不是客户因被错误收费而提交的 bug 报告。
**8. 选择规范隐含的协程作用域**
Yuri 的黑色星期五示例:授权、风控和欺诈检查过去是三个独立调用,各自在自己的 try/catch 块中运行。如果风控模块在高负载下运行缓慢,交易仍可能被批准,而欺诈检查可能静默失败,导致盗刷卡片被批准。Kotlin 提供了两个看似相似但行为完全不同的结构化并发作用域,规范(而非便利性)应决定使用哪一个。
// coroutineScope = 快速失败。← 部分成功是可捕获的异常 suspend fun authorize(req: Request) = coroutineScope { val auth = async { acquirer.authorize(req) } val risk = async { riskEngine.score(req) } val fraud = async { fraudService.check(req) } Outcome.from(auth.await(), risk.await(), fraud.await()) } // supervisorScope = 隔离。← 部分成功是目标 suspend fun fanOut(event: Event) = supervisorScope { listOf(async { sms(event) }, async { analytics(event) }, async { feed(event) }).awaitAll() } 在这里,coroutineScope 会快速失败:如果欺诈检查失败,授权和风控也会被取消,不会静默批准任何操作。supervisorScope 会允许子协程独立运行,这在像 fan-out 通知这种场景中是正确的选择,因为一个失败不应取消其余操作。问题不是哪种作用域更符合习惯用法,而是规范中说明当某个分支失败时应该发生什么。
**9. 将超时建模为领域结果,而非异常**
一个网关调用超时并不一定意味着失败。收单机构可能已经处理了该请求,只是客户端未能及时收到响应。将其作为通用异常捕获并继续处理,会抹去“未发生任何事”和“发生了某些事但不确定具体是什么”之间的区别。Yuri 的解决方案将 Timeout 建模为领域中的第一类结果。
sealed interface 授权结果 { data class 已批准(val authCode: AuthCode) : 授权结果 data class 已拒绝(val reason: 拒绝原因) : 授权结果 data object 超时 : 授权结果 // ← 规范动词,领域命名 }
when (val outcome = processAuth(req)) { is 已批准 -> settleTransaction(outcome.authCode) is 已拒绝 -> notifyCardHolder(outcome.reason) is 超时 -> initiateReversal(req) // 调用方根据规范决定处理方式 } 超时并非没有响应。它是一个独立状态,会导向对账查询而非重试,因为超时后盲目重试可能恰好上游已成功处理,这正是客户被重复扣款的常见原因。
**10. 让测试内容成为监管者可阅读的规范**
Yur 最出人意料的模式针对的是认证测试,而非应用代码。典型的认证套件运行约1000个测试,每个测试约200行代码,用他的话说,即使是架构师也难以阅读。团队将测试重写为 Kotlin DSL。以这种方式编写后,即使从未编写过 Kotlin 的认证专家也能阅读测试,对照规范理解逻辑,并直接签署确认。测试不再只是测试工程师才能验证的产物,而是变成了业务和认证专家可以审查的规范。
val purchaseOverFloorLimit = scenario("使用接触式卡片进行超过地板限额的购买必须在线处理") { terminal { floorLimit = 50.EUR } posRequest { purchase(amount = 75.EUR) } tap { card = TestCards.visaWithOnlinePIN }
expect { terminalDecision mustBe GoOnline outcome mustBe 已批准 } }
**实际发生的变化**
Yuri 对结果有明确说明:开发速度提升了2.7倍,认证首次尝试就仅出现一个缺陷并通过,这种结果足够特殊以至于监管机构询问发生了什么变化。最终代码库体积减少了约30%。代码审查从寻找遗漏的状态检查转变为逐句将代码与规范对比,因为编译器现在能自动捕获过去审查人员需要手动查找的错误类型。新工程师入职前通常需要两到三周时间才能理解5000页的规范和遗留代码库以交付任何内容。之后,新员工能在几天内提交生产环境的拉取请求。工程师也可以在设备团队和后端团队之间流动,这在使用两种不同语言的代码库时曾是困难的。Yuri 最后提到的影响是,工程师开始熟练掌握业务语言,能够直接与产品经理、销售和监管机构沟通,偶尔甚至会主动提出对监管规则本身的修改建议。
**无需重写的支付系统现代化**
Yuri 明确指出大多数团队的错误做法:将迁移视为一个项目,用 Kotlin 进行重写。他的系统运行着25年前的 Java 和 C++ 代码,每秒处理实时交易,且无法接受任何停机时间,因此重写从未被考虑过。当被描述为重构与重写的选择时,对于一个实时支付平台来说,答案几乎总是重构。他的替代方案是并行运行的四个轨道,而非按顺序执行。
- 首先优化现有的代码库,使用 Java 或 C++ 进行开发,无需更改语言;现在就现代化代码结构 – 共享布局、整洁架构、尖叫架构 – 即使在任何 Kotlin 代码引入之前。
- 转向验收测试驱动开发,而非单元测试驱动开发。工程团队之外的任何人都不关心函数内部的实现;但所有人都关注端到端的行为。
- 构建健康函数,用于检查合规性、安全性、可扩展性以及其他“-lities”(如可靠性、可用性等),这些检查应与迁移过程并行执行,而不是在最后阶段才进行评估。
- 逐步迁移:优先引入新的适配器,然后是新的领域模型,最后是变化最频繁的遗留代码,努力将“共享端口”的数量减少至零。Kotlin 与 Java 的互操作性正是使这种渐进式迁移路径成为可能的关键:新的适配器可以与未修改的 Java 代码共存,而无需经历双重维护期。
根据 Yuri 的说法,同时推进这四个方向,是使支付系统现代化在受监管环境中既安全又具成本效益的关键。而如果按顺序推进这些步骤,迁移过程反而容易陷入停滞。
**迁移是一项投资决策,而非语言偏好**
Yuri 指出在企业中真正需要说服的对象不是工程团队。他引用了 Uber 团队在前一天同一活动上的一场演讲(视频链接),表达了相似的观点:即使是以快速行动著称的公司,在达到企业级规模后,也会将语言迁移视为一项投资决策。一个可信的商业案例必须考虑新旧系统并行运行的成本、替换系统的停用成本、团队的学习曲线,以及迁移过程本身的风险。获得迁移批准并不等同于其被实际采用。如果没有一套明确的日常运营模式来支撑迁移的执行,Yuri 直白地表示:结局不会是任何意义上的“皆大欢喜”。
他指出了一项针对这一空白的资源:Kotlin 官方网站上发布的 22 页企业实践指南(在此次演讲前两天发布),用他的话说,这份指南涵盖了“所有人都会跳过的部分”。
**用一句话概括 Yuri Geronimus 的核心方法是什么?**
按照规范的字面意思编写代码,并让编译器强制执行这些规范,而不是依赖纪律或文档来发现规范与实现之间的差距。
**是否必须重写遗留的 Java 或 C++ 系统才能应用这些模式?**
不需要。Yuri 明确反对将迁移视为重写项目。他的四个方向可以并行推进:优化现有代码库、转向验收测试、更加关注健康函数,以及从适配器和领域模型开始逐步迁移,最后处理变化最频繁的部分。
**为什么不能依靠纪律或代码审查来发现这些错误?**
在 Yuri 所处的规模(全球 20 个团队),纪律无法均匀地扩展到所有团队。而编译器检查则无论谁在值班、无论某天审查者有多仔细,都能在所有地方、每次执行时一视同仁地应用。
- 终端背后的世界
- 一个词如何以六种方式破坏支付系统
- 方法论:先编写规范,再让编译器检查你的工作
- 1. 用于规范名词的值类
- 2. 验证工厂
- 3. 边界处的非空类型
- 4. 默认屏蔽敏感数据
- 5. 为每个状态创建独立的类,而不仅仅是独立的名称
- 6. 不可变性与命名转换
- 7. 删除 else 分支
- 8. 选择规范隐含的协程作用域
- 9. 将超时建模为领域结果而非异常
- 10. 让测试内容成为监管者可读的文档
- 实际推动变革的因素
- 无需重写代码的支付系统现代化
- 迁移是投资决策而非语言偏好
- 用一句话描述尤里·杰罗尼姆斯的核心方法是什么?
- 使用这些模式是否必须重写遗留的Java或C++系统?
- 为什么不是纪律要求或代码审查能发现这些漏洞?
- 进一步阅读