返回资讯中心
外部精选
软件工程
#开发工具#kotlin#kotlinconf#kotlin-conf-2026

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

This article was written by an external contributor. Yuri Geronimus’s four-year-old son once asked him what his job was. Yuri told him he writes books: books that describe what he wants to change in the physical world. …

JetBrains BlogAlina Dolgikh2026/10/14 · 23 天前30 分钟阅读

以下正文同步自 JetBrains Blog,版权归原站所有,已转换为易读排版。

Yuri Geronimus’s four-year-old son once asked him what his job was. Yuri told him he writes books: books that describe what he wants to change in the physical world. His son didn’t fully get it. Neither would most engineers: if you understand the words in your application code, you build good systems. If you don’t, you build bugs. That’s the spine of Yuri Geronimus’s KotlinConf 2026 talk, “Kotlin as a Safety Net: Type-Driven Reliability in the Finance Industry.” Yuri is an architect at Verifone, where he works across payment terminals, gateways, and the systems that connect them to banks and regulators in EMEA region. His method, in one line: write code in the exact words of the regulator or card network specs, and let the compiler enforce it. Enforce not by discipline, but by compiler. Watch the KotlinConf 2026 talk Watch Yuri Geronimus’s KotlinConf 2026 talk to learn how Kotlin helps build reliable payment systems. The world behind the terminal Before the patterns, Yuri set the stakes. A “payment” isn’t one app. It’s a mobile sales app, a point-of-sale app, a payment terminal running on a locked-down OS, payment gateways, terminal management systems, merchant platforms, and, underneath it all, processors, banks, and regulators. Each country carries thousands of pages of specifications and roughly 1,000 scenarios that must be met before anything reaches production. Some of those terminals have to keep working offline for up to 40 days, by regulation, because in the country they operate in, a payment terminal is critical infrastructure on the same level as a gas station or a pharmacy. Payment teams also have to satisfy contradictory rules from different regulators governing the same shared infrastructure: one country requires a behavior, another forbids it, and both must be enforced in the same codebase. That’s the environment the type system has to hold up in. Not a demo. Production, worldwide, for decades, not for a year. Six ways one word breaks a payment system Yuri walked through real incident patterns that he heard from his 15 years in payments and banks, each one traced back to a single missing or misused word:

  • An incorrect field caused a reversal, and someone else’s money landed in the wrong account.
  • A mandatory field was made nullable “just in case.” When a firmware update stopped sending it, the code fell back to a default, and part of the terminal fleet stopped processing payments.
  • A card number ended up in a log line and a stack trace: a disclosure obligation, and a real compliance problem.
  • A transaction got refunded after it had already been reversed, and a merchant was charged twice.
  • An authorization succeeded right as a timeout fired, and money moved to theft Each of these traces back to the same root cause: somewhere, the code disagreed with the specification about what a word meant, and nothing forced that disagreement into the open before it reached production. The method: write the spec, then let the compiler check your work Yuri described this as a set of patterns, not a framework. Ten of them, all doing the same job: making illegal states impossible to construct, not just invalid ones you catch later.
  1. Value classes for spec nouns Fraud and risk models carry dozens of similarly shaped IDs: merchant ID, terminal ID, acquirer ID, and more, several of which are literally identical in format due to regulation. As plain strings, nothing stops a refactor or copy-paste from swapping two of them, and the transaction silently routes to the wrong acquirer, for example. Yuri’s fix is a value class per 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) Swap the arguments now, and the code doesn’t compile. The mistake that used to reach production becomes a build error on the engineer’s own machine.
  2. Validate factories Every external field, whether it comes from a bank’s API or a terminal’s firmware, arrives as an untyped string that might not mean what the spec assumes. Yuri’s pattern parses it immediately into a sealed result at the boundary rather than trusting it downstream. 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) // happy path is ParsedStan.Invalid -> reject(parsed) // structured rejection } // no else, no try/catch, no Result to unwrap — exhaustiveness is enforced This approach Yuri thinks is a very good practice: it doesn’t throw exceptions; it helps prevent the not-best approach with Result, which hides the business domain, and prevents runCatching pitfalls in coroutines
  3. Non-null types at the boundary Many times, mandatory fields are marked nullable “just in case”, which requires juggling with silent fallbacks that often hide the real culprit. Yuri suggests leveraging Kotlin’s null-safety to fail loudly in one place: data class KernelResult( val tvr: TerminalVerificationResults, // ← not Optional, not nullable ) fun fromFirmware(raw: FirmwareResponse) = KernelResult( tvr = requireNotNull(raw.tvr) { "TVR null - firmware contract violated" } ) // One loud failure at the boundary. Never a silent fallback downstream This is the fix for the terminal-fleet outage: the field that was made nullable “just in case” never got a silent default again. It fails once, loudly, at the one predictable place it’s parsed, where it can be monitored, instead of failing quietly somewhere downstream where nobody is watching for it.
  4. Mask sensitive data by default Card numbers travel through logs and stack traces as plain strings, and that’s a disclosure obligation waiting to happen. Yuri wraps the value in a class with a private constructor and overrides toString(), so the safe behavior is the default. @JvmInline value class Pan private constructor(private val value: String) { override fun toString() = "PAN[]" // ← always safe fun masked() = / masking according spec / fun toNetwork() = value // deliberate, grep-able } logger.info("Processing card $card") // → "PAN[]". Safe by default An accidental log.info(“processing $pan”) now prints four masked digits. Getting the real value out, the one thing that’s genuinely needed to talk to the network, requires an explicit, auditable call. Nothing about that path is invisible anymore. Small note from Yuri: in real systems it hid much deeper.
  5. Give every state its own class, not just its own name A transaction that’s been refunded should never be able to call refund again, but a single mutable Transaction class with a status field relies on someone remembering to check that status everywhere. Yuri’s next-level version gives each state its own data class, so the method cannot change transaction state from the wrong data class; this ensures that a transition that shouldn’t happen (for example, refunding a reversed transaction) is blocked by the compiler. 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") } // exhaustive. the compiler refuses to forget.
  6. Immutability and named transition This continues the previous pattern. Make class with your business data immutable and give every transition a name. The name is a verb from the spec. Any method that shouldn’t be callable simply doesn’t exist: @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 called this his favorite pattern because it affects how engineers read a spec. This changes how engineers think and speak about the business domain.
  7. Delete the else branch If a specification lists every case explicitly and never says “otherwise,” then an else in the code is inventing a case the spec doesn’t describe. His team had handled only full refunds. When partial refunds became a real scenario, the old else branch would have absorbed it silently: compiles fine, tests pass, and it’s wrong in production. Delete the else, and the same new case becomes a compile error that points to every place needing an update. fun authorize(request: AuthorizationRequest): Authorization = when (request) { is PurchaseRequest -> /* ... / is FullRefundRequest -> / ... */ // ← no else. Adding a new state BREAKS compilation here } The business drifts constantly quarterly and monthly in payment in one country, and for the whole EMEA region – it becomes daily at Yuri’s scale. An exhaustive when turns every drift into a list of exactly what needs to change, rather than a bug report from a customer who was charged the wrong amount.
  8. Choose the coroutine scope the spec implies Yuri’s Black Friday example: authorization, risk management, and fraud checks used to run as three independent calls, each in its own try/catch. If fraud management ran slowly under load, the transaction could still be approved while the fraud check silently failed, and stolen cards could start getting approved. Kotlin gives two structured concurrency scopes that look similar and behave completely differently, and the spec, not convenience, should decide which one you use. // coroutineScope = fail-fast. ← partial success is an incident that you can catch 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 = isolate. ← partial success is the goal suspend fun fanOut(event: Event) = supervisorScope { listOf(async { sms(event) }, async { analytics(event) }, async { feed(event) }).awaitAll() } Here, coroutineScope fails fast: if the fraud check fails, authorization and risk are canceled as well, and nothing is silently approved. A supervisorScope would let siblings keep running independently, which is the right call for something like fan-out notifications, where one failure shouldn’t cancel the rest. The question isn’t which scope is more idiomatic. It’s what the spec says should happen when one branch fails.
  9. Model timeout as a domain outcome, not an exception A gateway call that times out isn’t necessarily a failure. The acquirer may have already processed it; the client just didn’t hear back in time. Catching that as a generic exception and moving on erases the difference between “nothing happened” and “something happened, we’re just not sure what.” Yuri’s fix models a Timeout as a first-class outcome in the domain. sealed interface AuthorizationOutcome { data class Approved(val authCode: AuthCode) : AuthorizationOutcome data class Declined(val reason: DeclineReason) : AuthorizationOutcome data object TimedOut : AuthorizationOutcome // ← spec verb, named in domain } when (val outcome = processAuth(req)) { is Approved -> settleTransaction(ou

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →