Kotlin logo

Kotlin

A concise multiplatform language developed by JetBrains

Kotlin KotlinConf

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

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<T> 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<T>, 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(outcome.authCode)
    is Declined  -> notifyCardHolder(outcome.reason)
    is TimedOut  -> initiateReversal(req)  // caller decides what to do according to spec
}

TimedOut isn’t the absence of an answer. It’s a distinct state that routes to a reconciliation query rather than a retry, because a naive retry after a timeout that actually succeeded upstream is exactly how a customer ends up charged twice.

10. Make the tests something a regulator can read

Yur’s least expected pattern targets certification tests, not application code. A typical certification suite runs a thousand tests at roughly 200 lines each, and, in his words, not even an architect can read that. The team rewrote the tests as Kotlin DSL. Written that way, a certification specialist who has never written Kotlin can read the test, follow the logic against the specification, and sign off on it directly. The test stops being an artifact only test engineers can verify and becomes a specification the business and certification specialists can review.

val purchaseOverFloorLimit = scenario("Purchase with tap card above floor limit must go online") {
    terminal { floorLimit = 50.EUR }
    posRequest  { purchase(amount = 75.EUR) }
    tap { card = TestCards.visaWithOnlinePIN }

    expect {
        terminalDecision  mustBe  GoOnline
        outcome           mustBe  Approved
    }
}

What actually moved

Yuri is specific about the outcomes: development ran 2.7 times faster, and certification passed with only one defect and on the first attempt, unusual enough that regulators asked what had changed. The resulting codebase was about 30% smaller. Code review shifted from hunting for missed state checks to comparing code against the specification sentence by sentence, because the compiler now catches the class of bug reviewers used to look for by hand. Onboarding a new engineer used to take two or three weeks before they understood enough of a 5,000-page specification and a legacy codebase to ship anything. Afterward, new hires opened a production pull request within days. Engineers could also move between device teams and backend teams, something a split codebase in two different languages had made difficult. The last effect Yuri called out was that engineers started speaking the business’s language well enough to talk directly with product managers, sales, and regulators, occasionally proposing changes to the regulations themselves.

Payments modernization without a rewrite

Yuri is direct about the mistake most teams make: treating migration as one project, a rewrite in Kotlin. His systems run 25-year-old Java and C++ code processing live transactions every second, with no acceptable downtime, so a rewrite was never on the table. Framed as a refactor-vs-rewrite decision, the answer for a live payments platform is almost always refactor. His alternative is four tracks that run in parallel, not in sequence.

  1. Optimize the existing codebase first, in Java or C++, without touching the language; modernize code structure now – shared layout, clean architecture, screaming architecture – even before any Kotlin lands.
  2. Shift to acceptance-test-driven development, not unit-test-driven. Nobody outside the engineering team cares what’s inside a function; everybody cares about the end-to-end behavior.
  3. Build fitness functions to check compliance, security, scalability, and other “-lities” that run alongside the migration rather than being assessed at the end.
  4. Migrate incrementally: new adapters first, then new domain models, then the legacy code that changes most often, working to reduce the number of “shared ports” to zero. Kotlin’s interop with Java is what makes this incremental path possible at all: a new adapter can sit alongside unmodified Java code without a dual-maintenance period.

Running all four at once, according to Yuri, is what makes a payments modernization effort safe and cost-effective in a regulated environment. Running them in sequence is what makes migrations stall.

Migration is an investment decision, not a language preference

Yuri pointed out who needs convincing in an enterprise: it isn’t the engineering team. He referenced a talk from Uber’s team the day before his, at the same event, making a similar point: even a company known for moving fast treats language migration as an investment decision once it’s operating at enterprise scale. A credible business case has to account for the costs of running old and new systems in parallel, the costs of decommissioning what’s being replaced, the learning curve for the team, and the risk of the transition itself. Getting the migration approved is not the same as its adoption. Without an operating model for how the migration actually runs day-to-day, Yuri was blunt: there’s no happy ending waiting at the end of it.

He pointed to a resource for that exact gap: a 22-page playbook on the Kotlin site, published two days before this talk, covering, in his words, “the part that everybody skips.”

What is Yuri Geronimus’s core method, in one sentence?

Write code in the literal words of the specification and let the compiler enforce them, rather than relying on discipline or documentation to catch gaps between the two.

Do you have to rewrite a legacy Java or C++ system to use these patterns?

No. Yuri explicitly argues against treating migration as a rewrite project. His four tracks run in parallel: optimizing the existing codebase, shifting to acceptance testing, more focus on  fitness functions, and migrating incrementally, starting with adapters and the domain models, then going from the most-often-changed parts.

Why doesn’t discipline or code review catch these bugs instead?

At the scale Yuri operates, 20 teams worldwide, discipline doesn’t scale evenly. A compiler check applies identically everywhere, every time, regardless of who’s on call or how careful any one reviewer is that day.

Further reading

Geronimus writes more broadly on Kotlin in payments and fintech in his own posts on Medium. For the business case and operating model he referenced in the talk, the resource is JetBrains’ enterprise Kotlin adoption playbook, also written with Geronimus.