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

Yuri Geronimus& 8217;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& 8217;t fully get it.& 160; Neither would most engineers: if you understand the words in your application code, you build good…

Alina Dolgikh ۱۱ دقیقه مطالعهمتن اصلی

Aniket Bhattacharyea

Aniket is a Mathematics postgraduate who has a passion for computers and software. He likes to explore various areas related to coding and works as a web developer using Ruby on Rails and Next.JS.

Website

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 the 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 the compiler.

Watch the KotlinConf 2026 talk

Watch Yuri Geronimus’s KotlinConf 2026 talk to learn how Kotlin helps build reliable payment systems.

A Legacy Modernization Strategy and Business Case Playbook for Enterprise Kotlin Adoption

Read the Playbook

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 a year.

Six ways one word breaks a payment system

Yuri walked through real incident patterns 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.
  • An authorization was approved while the fraud check timed out, and money was stolen.
  • A request timed out, but the authorization had succeeded upstream, and the customer paid twice.

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 MerchantId(val value: String)
@JvmInline value class AcquirerId(val value: String)
@JvmInline value class AmountMinor(val value: String)

data class AuthRequest(val merchantId: MerchantId, val acquirerId: AcquirerId, val amount: AmountMinor)

fun demo(merchantId: MerchantId, acquirerId: AcquirerId, amount: AmountMinor) {
    AuthRequest(merchantId, acquirerId, amount)   // ✅ compiles
    // AuthRequest(acquirerId, merchantId, amount)   // ❌ type mismatch: swapped arguments do not compile
}

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.

@JvmInline value class RawStan(val value: String)   // untrusted input
@JvmInline value class Stan(val value: String)      // validated, per spec

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
    }

    companion object {
        fun of(rawStan: RawStan): ParsedStan = when {
            rawStan.value.length != 6         -> Invalid.WrongLength(rawStan)
            !rawStan.value.all(Char::isDigit) -> Invalid.NotNumeric(rawStan)
            else                              -> 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

fun demo(raw: RawStan) {
    when (val parsed = ParsedStan.of(raw)) {   // bad input is a value, not an exception
        is ParsedStan.Valid               -> gateway.authorize(parsed.stan)
        is ParsedStan.Invalid.WrongLength -> reject(parsed)
        is ParsedStan.Invalid.NotNumeric  -> reject(parsed)
        // forget any of the cases → ❌ 'when' expression must be exhaustive
    }
}

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

fun demo(raw: FirmwareResponse) {
    // raw.tvr ?: TerminalVerificationResults()   // ❌ "just in case" fallback: silently hides the broken contract
    val result = fromFirmware(raw)   // one loud failure here, at the single parsing point where it is monitored
    val tvr: TerminalVerificationResults = result.tvr   // non-null: nothing downstream has to guard against it
}

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.

// Illustration of the idea: in production this is hidden much deeper (PCI scope, key management, encryption)
@JvmInline
value class Pan private constructor(private val value: String) {
    override fun toString() = "PAN[****]"                         // ← always safe
    fun masked(): String = TODO("Pan.masked: masking according to spec")
    fun toNetwork() = value                                       // deliberate, grep-able

    companion object {
        fun of(raw: String): Pan? = TODO("Pan.of: the only way in – validate length, digits and Luhn, then construct")
    }
}

fun demo(card: Pan) {
    logger.info("Processing card $card")   // → "Processing card PAN[****]". Safe by default
    val onWire = card.toNetwork()          // the real value only through an explicit, grep-able call
}

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.

import TransactionStatus.Captured
import TransactionStatus.FullyRefunded
import TransactionStatus.Reversed
import java.time.Instant

// One class per state: each state carries only the data that is valid in it
sealed interface TransactionStatus {
    data class Captured(val amount: AmountMinor)    : TransactionStatus
    data class FullyRefunded(val at: Instant)       : TransactionStatus
    data class Reversed(val reason: ReversalReason) : TransactionStatus
}

// Refund exists only on Captured: that is where the spec allows it
fun Captured.fullRefund(refundReason: RefundReason) = RefundAuthorization(amount, refundReason)

fun demo(captured: Captured, refunded: FullyRefunded, reversed: Reversed, reason: RefundReason) {
    captured.fullRefund(reason)   // ✅ compiles
    // refunded.fullRefund(reason)   // ❌ unresolved reference: a refunded transaction cannot be refunded again
    // reversed.fullRefund(reason)   // ❌ unresolved reference: a reversed transaction cannot be refunded
}

6. Immutability and named transitions

This continues the previous pattern. Make the 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+: copy() is as private as the constructor
data class PurchasedAuthorization private constructor(
    val stan: Stan,
    val amount: AmountMinor,
) : Authorization {
    fun fullRefund(refundReason: RefundReason) = RefundAuthorization(stan, amount, refundReason)
    // no reverseAuthorization(): the spec does not allow it for a purchase
}

fun demo(purchaseAuthorization: PurchasedAuthorization, reason: RefundReason) {
    purchaseAuthorization.fullRefund(reason)         // ✅ compiles
    // purchaseAuthorization.reverseAuthorization()  // ❌ compile error: unresolved reference 'reverseAuthorization'
}

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   -> purchase(request)
    is FullRefundRequest -> fullRefund(request)
    // ← no else. Adding a new state BREAKS compilation here
}

fun demo(purchase: PurchaseRequest, refund: FullRefundRequest) {
    authorize(purchase)   // ✅ compiles
    authorize(refund)
    // spec adds: class PartialRefundRequest : AuthorizationRequest
    // → ❌ authorize() stops compiling: 'when' must be exhaustive
}

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.

import kotlinx.coroutines.*
// coroutineScope = fail-fast.   ← partial success is an incident: one failure cancels the rest
// In production each call also gets its own timeout, and a timeout is an outcome, not a thrown exception
// (full version with tests: production/08_coroutine_scopes)
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
// Note: runCatching also catches CancellationException. In production rethrow it
// (e.g. `catch (e: CancellationException) { throw e }`) so cancelling the parent still works.
suspend fun fanOut(event: Event) = supervisorScope {
    listOf(
        async { runCatching { sms(event) } },
        async { runCatching { analytics(event) } },
        async { runCatching { feed(event) } },
    ).awaitAll()   // never throws: each failure is a Result.failure in the list
}

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.

import AuthorizationOutcome.Approved
import AuthorizationOutcome.Declined
import AuthorizationOutcome.TimedOut

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
}

fun demo(req: Req) {
    when (val outcome = processAuth(req)) {
        is Approved -> settleTransaction(outcome.authCode)
        is Declined -> notifyCardHolder(outcome.reason)
        is TimedOut -> reconcile(req)  // not retry: it may have succeeded upstream
    }
}

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

Yuri’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

Yuri 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 Yuri Geronimus.

KB

منتشرشده در

Kotlin Blog

خواندن در منبع

ادامه مطالعه