The JetBrains Blog

KotlinConf Recap: Lessons from Verifone’s Architect on Kotlin in Mission-Critical Payments

6.9内容质量
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 分析暂不可用,本条为保底评分与摘要。
#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内容:

图片1:Kotlin标志
图片1:Kotlin标志

由JetBrains开发的简洁多平台语言

KotlinKotlinConf

KotlinConf回顾:来自Verifone架构师关于Kotlin在关键支付系统中应用的经验教训

图片2:Alina Dolgikh
图片2:Alina Dolgikh

2026年10月14日

Yuri Geronimus四岁的儿子曾问他工作是什么。Yuri告诉他,他写书:写描述自己想要改变的物理世界的书。他的儿子并没有完全理解。

大多数工程师也不会完全理解:如果你理解应用程序代码中的词语,你就能构建良好的系统;如果不理解,你构建的将是漏洞。

这正是Yuri Geronimus在KotlinConf 2026上的演讲“Kotlin作为安全网:金融行业中的类型驱动可靠性”的核心思想。Yuri是Verifone的架构师,他在EMEA地区支付终端、网关以及连接银行和监管机构的系统中工作。他的方法用一句话概括:用监管机构或银行卡网络规范中的精确措辞编写代码,并让编译器强制执行。不是通过纪律,而是通过编译器。

观看KotlinConf 2026演讲

观看Yuri Geronimus的KotlinConf 2026演讲,了解Kotlin如何帮助构建可靠的支付系统。

视频3

图片3
图片3

企业级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的空安全机制,在一个位置明确失败:

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()方法,使安全行为成为默认选项。

kotlin
@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的进阶方案为每个状态创建独立的数据类,使方法无法从错误的数据类修改交易状态;这确保了不应发生的转换(例如对已撤销交易进行退款)会被编译器阻止。

kotlin
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. 不可变性与命名转换**

这延续了之前的模式。将业务数据类设为不可变,并为每个转换操作命名。名称来自规范中的动词。任何不应被调用的方法直接不存在:

kotlin
@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++ 代码,每秒处理实时交易,且无法接受任何停机时间,因此重写从未被考虑过。当被描述为重构与重写的选择时,对于一个实时支付平台来说,答案几乎总是重构。他的替代方案是并行运行的四个轨道,而非按顺序执行。

  1. 首先优化现有的代码库,使用 Java 或 C++ 进行开发,无需更改语言;现在就现代化代码结构 – 共享布局、整洁架构、尖叫架构 – 即使在任何 Kotlin 代码引入之前。
  2. 转向验收测试驱动开发,而非单元测试驱动开发。工程团队之外的任何人都不关心函数内部的实现;但所有人都关注端到端的行为。
  3. 构建健康函数,用于检查合规性、安全性、可扩展性以及其他“-lities”(如可靠性、可用性等),这些检查应与迁移过程并行执行,而不是在最后阶段才进行评估。
  4. 逐步迁移:优先引入新的适配器,然后是新的领域模型,最后是变化最频繁的遗留代码,努力将“共享端口”的数量减少至零。Kotlin 与 Java 的互操作性正是使这种渐进式迁移路径成为可能的关键:新的适配器可以与未修改的 Java 代码共存,而无需经历双重维护期。

根据 Yuri 的说法,同时推进这四个方向,是使支付系统现代化在受监管环境中既安全又具成本效益的关键。而如果按顺序推进这些步骤,迁移过程反而容易陷入停滞。

**迁移是一项投资决策,而非语言偏好**

Yuri 指出在企业中真正需要说服的对象不是工程团队。他引用了 Uber 团队在前一天同一活动上的一场演讲(视频链接),表达了相似的观点:即使是以快速行动著称的公司,在达到企业级规模后,也会将语言迁移视为一项投资决策。一个可信的商业案例必须考虑新旧系统并行运行的成本、替换系统的停用成本、团队的学习曲线,以及迁移过程本身的风险。获得迁移批准并不等同于其被实际采用。如果没有一套明确的日常运营模式来支撑迁移的执行,Yuri 直白地表示:结局不会是任何意义上的“皆大欢喜”。

他指出了一项针对这一空白的资源:Kotlin 官方网站上发布的 22 页企业实践指南(在此次演讲前两天发布),用他的话说,这份指南涵盖了“所有人都会跳过的部分”。

**用一句话概括 Yuri Geronimus 的核心方法是什么?**

按照规范的字面意思编写代码,并让编译器强制执行这些规范,而不是依赖纪律或文档来发现规范与实现之间的差距。

**是否必须重写遗留的 Java 或 C++ 系统才能应用这些模式?**

不需要。Yuri 明确反对将迁移视为重写项目。他的四个方向可以并行推进:优化现有代码库、转向验收测试、更加关注健康函数,以及从适配器和领域模型开始逐步迁移,最后处理变化最频繁的部分。

**为什么不能依靠纪律或代码审查来发现这些错误?**

在 Yuri 所处的规模(全球 20 个团队),纪律无法均匀地扩展到所有团队。而编译器检查则无论谁在值班、无论某天审查者有多仔细,都能在所有地方、每次执行时一视同仁地应用。

  1. 观看 KotlinConf 2026 演讲
  2. 面向企业 Kotlin 采用的遗留系统现代化战略与商业案例手册
  1. 终端背后的世界
  2. 一个词如何以六种方式破坏支付系统
  3. 方法论:先编写规范,再让编译器检查你的工作
  4. 1. 用于规范名词的值类
  5. 2. 验证工厂
  6. 3. 边界处的非空类型
  7. 4. 默认屏蔽敏感数据
  8. 5. 为每个状态创建独立的类,而不仅仅是独立的名称
  9. 6. 不可变性与命名转换
  10. 7. 删除 else 分支
  11. 8. 选择规范隐含的协程作用域
  12. 9. 将超时建模为领域结果而非异常
  13. 10. 让测试内容成为监管者可读的文档
  1. 实际推动变革的因素
  2. 无需重写代码的支付系统现代化
  3. 迁移是投资决策而非语言偏好
  4. 用一句话描述尤里·杰罗尼姆斯的核心方法是什么?
  5. 使用这些模式是否必须重写遗留的Java或C++系统?
  6. 为什么不是纪律要求或代码审查能发现这些漏洞?
  7. 进一步阅读

了解更多