Skip to main content

Naming the Callback: Kotlin fun interface Without the Production Scars

SAM conversion cuts the boilerplate, but leaks, re-entrancy, and API drift still bite. Six failure modes and the fixes for each.

Updated 12 min readMarkdown
Naming the Callback: Kotlin fun interface Without the Production Scars

🧠 TL;DR (Too Long; Didn’t Read)

  • A fun interface is an interface with exactly one abstract method that callers can implement with a lambda.
  • It removes the anonymous-object ceremony and keeps a named, documented, reusable type.
  • Reach for it when the callback is a contract (it has a name, rules, and a lifecycle). Use a plain function type when it is not.
  • The syntax is the easy part. The real risks are identity, captured references, re-entrancy, threading, exceptions, and API evolution.

The problem with anonymous objects

Every Android codebase eventually accumulates callbacks. Here is a typical one: a camera-backed barcode scanner that hands decoded values to the screen and asks, “should I keep scanning?”

class Barcode(val value: String, val isValid: Boolean)

enum class ScanAction { CONTINUE, STOP }

interface ScanHandler {
    fun onScan(code: Barcode): ScanAction
}

With a regular interface, every call site pays an entrance fee:

scanner.setHandler(object : ScanHandler {
    override fun onScan(code: Barcode): ScanAction {
        return if (code.isValid) ScanAction.STOP else ScanAction.CONTINUE
    }
})

Five lines, and exactly one of them is a decision. The rest is plumbing the compiler could write for you.

ceremony-vs-intent

Add one modifier to the interface and the plumbing disappears:

fun interface ScanHandler {
    fun onScan(code: Barcode): ScanAction
}

scanner.setHandler { code ->
    if (code.isValid) ScanAction.STOP else ScanAction.CONTINUE
}

Nothing about the type changed. setHandler still takes a ScanHandler, Java callers still see a ScanHandler, and the lambda is converted to an instance of it at the call site. This is SAM (single abstract method) conversion, and the fun modifier is how you opt a Kotlin interface into it.


Mechanics worth knowing

Two ways to produce an instance. Pass a lambda where a ScanHandler is expected, or call the SAM constructor explicitly when you need a standalone value:

val strict = ScanHandler { code ->
    if (!code.isValid) return@ScanHandler ScanAction.CONTINUE
    ScanAction.STOP
}

The label for an early return is the interface name, which is why return@ScanHandler works.

Rules for the declaration. Exactly one abstract member, and it must be a function (no abstract properties). You may add any number of non-abstract members, which is where the pattern gets interesting.

Reusable behavior. Because the contract is a real type, you can write functions that take and return it:

fun ScanHandler.ignoringInvalid(): ScanHandler = ScanHandler { code ->
    if (code.isValid) onScan(code) else ScanAction.CONTINUE
}

Try writing that decorator against an anonymous object : expression and you will see why the lambda form matters.

What you get at runtime. On recent Kotlin versions the compiler generally lowers these conversions through invokedynamic. A lambda that captures nothing can be reused by the runtime; one that captures variables allocates per evaluation. Treat the identity of the resulting instance as unspecified. That single sentence explains the first production failure below.


Fun interface, function type, or typealias?

A function type like (Barcode) -> ScanAction is perfectly good Kotlin. So is a typealias. The question is whether your callback has become a contract: something with a name people say out loud, rules that need documenting, and collaborators that must tell it apart from other callbacks.

callback-decision-flow

The flow above is the decision path. The matrix below shows what each shape actually buys you, which is where teams usually get surprised: a typealias is only a nickname, so two aliases over the same function type are interchangeable to the compiler and invisible to your DI graph.

callback-shapes-matrix

The rule of thumb: if you would hesitate to write the KDoc for the callback in one sentence, it is a contract, and it deserves a name.


A realistic engine to hang failures on

To make the failure modes concrete, here is a trimmed scanner engine. It is the kind of app-scoped object that outlives every screen.

/**
 * Receives decoded barcodes from [ScanEngine].
 * Always invoked on the executor given to the engine (the main thread in production).
 */
fun interface ScanHandler {
    /** Return [ScanAction.STOP] to end scanning for every handler. */
    fun onScan(code: Barcode): ScanAction
}

fun interface Registration {
    fun cancel()
}

class ScanEngine(private val callbackExecutor: Executor) {

    private val handlers = CopyOnWriteArraySet<ScanHandler>()

    @Volatile
    private var scanning = true

    fun register(handler: ScanHandler): Registration {
        handlers.add(handler)
        return Registration { handlers.remove(handler) }
    }

    // Called from the camera analyzer thread.
    fun onFrameDecoded(code: Barcode) {
        callbackExecutor.execute { dispatch(code) }
    }

    private fun dispatch(code: Barcode) {
        if (!scanning) return
        for (handler in handlers) {
            val action = try {
                handler.onScan(code)
            } catch (e: Exception) {
                Log.e("ScanEngine", "Handler failed; continuing", e)
                ScanAction.CONTINUE
            }
            if (action == ScanAction.STOP) {
                scanning = false
                return
            }
        }
    }
}

Every design choice above exists because of a failure described next. Production wiring is a one-liner: ScanEngine(ContextCompat.getMainExecutor(context)).


Failure mode 1: unregistering with a “new” lambda

Many listener APIs, including several in the Android SDK, remove by instance. Imagine a first-generation version of our engine that worked that way (this is not the ScanEngine above; it is the design we are about to replace):

interface LegacyScanEngine {
    fun addHandler(handler: ScanHandler)
    fun removeHandler(handler: ScanHandler)
}

The natural screen code against it looks like this:

override fun onStart() {
    super.onStart()
    legacyEngine.addHandler { code -> render(code); ScanAction.CONTINUE }
}

override fun onStop() {
    super.onStop()
    legacyEngine.removeHandler { code -> render(code); ScanAction.CONTINUE } // never matches
}

The two lambdas look identical, but each SAM conversion produces its own instance and instances compare by reference. removeHandler removes nothing, the original stays registered, and you now have a leak that compiles cleanly and passes a casual code review. It is worse in a debug build with a single screen, because everything appears to work.

Fix: never make callers re-create the key. Return a handle, which is exactly what ScanEngine.register does with Registration:

private var registration: Registration? = null

override fun onStart() {
    super.onStart()
    registration = engine.register { code -> render(code); ScanAction.CONTINUE }
}

override fun onStop() {
    super.onStop()
    registration?.cancel()
    registration = null
}

Cancellation no longer depends on lambda identity at all. If you must keep an add/remove pair for compatibility, document that callers need to hold the original instance, and consider deprecating it.


Failure mode 2: the captured this that outlives its screen

Even with a correct remove call, forgetting to make it is easy. The lambda below captures the Activity because render is a member of it:

class CheckoutActivity : AppCompatActivity() {
    override fun onStart() {
        super.onStart()
        // Leaks: the lambda captures this, and the returned Registration is dropped,
        // so nothing ever cancels it.
        engine.register { code -> render(code); ScanAction.CONTINUE }
    }
}

engine is app-scoped, so the chain from a GC root to your destroyed Activity is short and unbroken.

listener-leak-chain

Fix: tie registration to a lifecycle so forgetting is impossible:

fun ScanEngine.registerWhileStarted(owner: LifecycleOwner, handler: ScanHandler) {
    owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
        private var registration: Registration? = null

        override fun onStart(owner: LifecycleOwner) {
            registration = register(handler)
        }

        override fun onStop(owner: LifecycleOwner) {
            registration?.cancel()
            registration = null
        }
    })
}

The engine now holds your handler only between onStart and onStop. The observer lives inside the Activity's own lifecycle, so it adds no path from a GC root. In a fragment, pass viewLifecycleOwner if the handler touches views.


Failure mode 3: re-entrancy during dispatch

Handlers are real code written by real teammates. Sooner or later one will cancel itself, or register a sibling, from inside its own callback:

lateinit var registration: Registration
registration = engine.register { code ->
    registration.cancel() // mutates the collection we are iterating
    ScanAction.CONTINUE
}

If the engine stored handlers in a plain mutableSetOf, that line produces a ConcurrentModificationException on the next delivery, and only on that path. A copy-on-write set iterates over a snapshot, so mutation during dispatch is safe. You pay an array copy on each register and cancel, which is a good trade for a collection that is read on every frame and written a few times per screen.

Document the semantics you chose: a handler cancelled mid-dispatch may still receive the in-flight event.


Failure mode 4: one bad handler starves the rest

Without a guard, an exception from the first handler propagates out of the loop. Later handlers never see the code, and if the dispatch runs on the main executor, the process crashes.

The engine above catches Exception per handler, logs, and treats the failure as CONTINUE. Two judgment calls hide in that line:

  • Catch Exception, not Throwable. Swallowing OutOfMemoryError helps nobody.
  • If your dispatch ever becomes suspending, remember that catching Exception also catches CancellationException. Rethrow it explicitly.

Some teams prefer to crash in debug builds to surface bugs early. That is a legitimate policy as long as it is written down.


Failure mode 5: the wrong thread

Camera analyzers, socket readers, and database observers run on background threads. A handler that was written assuming the main thread will eventually touch a view from the wrong one.

This is where a named interface pays for itself. The threading rule belongs in the KDoc on onScan, where the IDE shows it to every implementer, and enforcement belongs in the engine: it takes a callbackExecutor, so delivery is always where the contract says. A bare (Barcode) -> ScanAction parameter has no place to put that sentence.


Failure mode 6: evolving a contract that lambdas depend on

Six months in, you want an error callback. Adding a second abstract method breaks every lambda call site, because the interface is no longer functional, and it breaks binary compatibility for compiled clients too.

fun interface ScanHandler {
    fun onScan(code: Barcode): ScanAction

    // Optional hook. Lambda callers cannot override it, object implementers can.
    fun onScanError(error: ScanError) {}
}

A non-abstract member keeps the interface functional. Two cautions for library authors:

  • Check your -jvm-default compiler setting. It decides whether Java callers see a true default method, which matters if you ship an SDK.
  • Treat the fun modifier as part of your public API. Adding it to an existing interface is compatible. Removing it breaks every caller that passes a lambda.

If you are about to need a second required operation, stop. That is the “regular interface” branch of the decision flow, or a sign to split into two small fun interfaces.


Testing gets pleasant

The same property that cleans up call sites cleans up tests. No mocking framework, no hand-written fake classes:

class ScanEngineTest {

    @Test
    fun `stop from one handler ends delivery to the others`() {
        val engine = ScanEngine { it.run() } // Executor, converted from a lambda
        val first = mutableListOf<String>()
        val second = mutableListOf<String>()
        engine.register { first += it.value; ScanAction.STOP }
        engine.register { second += it.value; ScanAction.CONTINUE }

        engine.onFrameDecoded(Barcode("A-1", isValid = true))

        assertEquals(listOf("A-1"), first)
        assertTrue(second.isEmpty())
    }

    @Test
    fun `a throwing handler does not starve the next one`() {
        val engine = ScanEngine { it.run() }
        val received = mutableListOf<String>()
        engine.register { error("boom") }
        engine.register { received += it.value; ScanAction.CONTINUE }

        engine.onFrameDecoded(Barcode("B-2", isValid = true))

        assertEquals(listOf("B-2"), received)
    }

    @Test
    fun `cancel removes the handler`() {
        val engine = ScanEngine { it.run() }
        var calls = 0
        val registration = engine.register { calls++; ScanAction.CONTINUE }

        registration.cancel()
        engine.onFrameDecoded(Barcode("C-3", isValid = true))

        assertEquals(0, calls)
    }
}

Each failure mode above is now a three-line regression test.


One more trap: two overloads for one idea

Offering both register(handler: ScanHandler) and register(block: (Barcode) -> ScanAction) feels generous. In practice it means every reader must know which overload the compiler picked for a given lambda, and the two can drift apart as the API evolves. Pick one shape per operation and stick to it.


Review checklist

  • Does the callback have a name your team would use in conversation? If not, a function type is fine.
  • Is there a KDoc on the method stating the thread, ordering, and return-value meaning?
  • Does registration return a handle instead of asking callers to rebuild the key?
  • Can a handler be registered by an object that outlives it? If so, is the lifetime tied to a lifecycle?
  • Is the collection safe to mutate during dispatch?
  • Does one handler’s exception stay contained?
  • If the contract grows, will you add a defaulted member rather than a second abstract one?

🔚 Closing thought

The fun keyword makes the syntax of a callback cheap. It does nothing about the semantics: who owns the reference, which thread calls it, what happens when it throws, and how it changes next year. Use it where a callback deserves to be a named, documented, reusable contract, and put the effort you saved on boilerplate into those rules.

Further reading: the official Kotlin documentation on functional (SAM) interfaces.


🚀 Master Android Architecture: Volume 2, Architecture at Scale

What happens when your app and your team grow? This volume takes NowReading into modularization, Gradle convention plugins, architecture for large teams and legacy migration. It then covers testing strategy, performance and security, and finishes with Kotlin Multiplatform, adaptive layouts, on-device AI and an end-to-end case study. The appendices collect all 26 ADRs, cheat sheets, a pattern comparison and a system design interview guide.

About the author

  • Android Engineer & Kotlin Advocate. 🚀 Deep dives into Jetpack Compose, Coroutines, and Clean Architecture. Turning complex code into scalable solutions. 🛠️✨

No comments yet. Be the first to comment.