At Epimorphics, our engineers spend much of their time building and maintaining production data services that need to be robust, maintainable and easy to evolve over many years. That experience often uncovers subtle behaviours that aren’t immediately obvious from the language documentation alone.
This TechTalk article is written by Simon Oakes, Senior Software Engineer at Epimorphics. Simon has extensive experience designing and developing software using Kotlin, Java and semantic technologies, with a particular focus on building reliable, production-quality systems. In this post he explores a subtle interaction between Kotlin interface delegation and default interface methods that can introduce unexpected behaviour as interfaces evolve.
If you use Kotlin’s by delegation feature- or are responsible for evolving shared interfaces – this is a corner case worth understanding.
The Problem with a Convenient Feature
This post is about a hidden pitfall of using Kotlin interface delegates,
which I will introduce by way of an example.
As the example unfurls, experienced Kotlin developers may be able to anticipate the issue.
At the end, I will briefly discuss some ideas of best practices to prevent it.
Kotlin delegates are defined by the by keyword and are useful for quickly implementing interface wrappers without any boiler plate.
However, Kotlin programmers must be wary of the consequences of combining delegates with default implementations on an interface.
Suppose we start with a simple Inbox interface which collects individual messages so that it can return them later.
interface Inbox {
fun addMessage(msg: String)
fun messages(): List<String>
}
We can imagine that this might be implemented by anything from a simple wrapper around a MutableList to an external database.
Here’s an example of the former:
class MemoryInbox: Inbox {
private val messages: MutableList<String> = mutableListOf()
override fun addMessage(msg: String) {
messages.add(msg)
}
override fun messages(): List<String> {
return messages.toList()
}
}
To verify the behaviour of this class, we can write the following test:
fun testMemoryInbox() {
val inbox = MemoryInbox()
inbox.addMessage("Hi.")
inbox.addMessage("Hello.")
val expectedMsgs = listOf("Hi.", "Hello.")
val actualMsgs = inbox.messages()
assertEquals(expectedMsgs, actualMsgs)
}
Now suppose we have to support a requirement that messages that don’t end with a full stop should be ignored.
A wrapper implementation suffices for this, and spares us from having to know the details of the underlying inbox.
class GrammaticallyFilteredInbox(
private val inbox: Inbox
): Inbox {
override fun addMessage(msg: String) {
if (msg.lastOrNull() == '.') {
inbox.addMessage(msg)
}
}
override fun messages(): List<String> {
return inbox.messages()
}
}
And the test:
fun testFilteredInbox() {
val inbox = MemoryInbox()
val filteredInbox = GrammaticallyFilteredInbox(inbox)
filteredInbox.addMessage("Hi.")
filteredInbox.addMessage("hello")
filteredInbox.addMessage("Salutations.")
val expectedMsgs = listOf("Hi.", "Salutations.")
val actualMsgs = filteredInbox.messages()
assertEquals(expectedMsgs, actualMsgs)
}
However, the new implementation of messages seems redundant – why not factor it out with an interface delegate?
class GrammaticallyFilteredInbox(
private val inbox: Inbox
): Inbox by inbox {
override fun addMessage(msg: String) {
if (msg.lastOrNull() == '.') {
inbox.addMessage(msg)
}
}
}
Now all functions of the underlying inbox that aren’t relevant to this wrapper are automatically delegated – it’s concise and effective.
But suppose that, later on, we decide to add bulk-processing support to the Inbox interface with a new addMessages method.
interface Inbox {
fun addMessage(msg: String)
fun addMessages(msgs: List<String>) // new
fun messages(): List<String>
}
The option to process messages in bulk may afford certain implementations a performance improvement.
But it will be incompatible with any other prior implementations which we don’t update to include this method,
so let’s provide a default addMessages which just calls addMessage repeatedly.
This might not be optimal in all cases, but at least it won’t stop other components from compiling!
interface Inbox {
fun addMessage(msg: String)
fun addMessages(msgs: List<String>) {
msgs.forEach(::addMessage)
}
fun messages(): List<String>
}
A quick test:
fun testMemoryInbox_addMessages() {
val inbox = MemoryInbox()
inbox.addMessages(listOf("Hi.", "Hello."))
val expectedMsgs = listOf("Hi.", "Hello.")
val actualMsgs = inbox.messages()
assertEquals(expectedMsgs, actualMsgs)
}
Now everything compiles without any further changes or backwards compatibility concerns.
But all is not well – check the GrammaticallyFilteredInbox behaviour:
fun testFilteredInbox_addMessages() {
val inbox = MemoryInbox()
val filteredInbox = GrammaticallyFilteredInbox(inbox)
filteredInbox.addMessages(listOf("Hi.", "hello", "Salutations."))
val expectedMsgs = listOf("Hi.", "Salutations.")
val actualMsgs = filteredInbox.messages()
assertEquals(expectedMsgs, actualMsgs)
}
This test fails – in fact, the messages were “Hi.”, “hello”, “Salutations.”.
The filter is suddenly not filtering out grammatically poor messages like it’s supposed to, despite the fact that we provided a reasonable default method implementation and received no hints from the compiler that the semantics of the Inbox interface have changed.
This is the hidden pitfall:
- When an interface wrapper delegates to the underlying implementation;
- And the interface has a default method implementation which calls a second method on the same interface;
- And the wrapper does not override that method;
- Then the default method implementation on the wrapper calls the second method on the delegate, not on the wrapper.
In our test case:
GrammaticallyFilteredInboxdelegatesInboxtoinbox;- The default
addMessagescallsaddMessage; GrammaticallyFilteredInboxdoes not overrideaddMessages.- The default
addMessagesonGrammaticallyFilteredInboxcallsaddMessageoninbox.
So when the default implementation of addMessages is invoked on GrammaticallyFilteredInbox, it actually calls the addMessage method of the underlying MemoryInbox, bypassing the all-important addMessage override on GrammaticallyFilteredInbox.
So what’s the remedy for this systematic issue? A few ideas:
- If we are responsible for both the interface and the implementation, we should accompany new default methods with a suite of tests for each implementation which inherits them.
- Pro – This ensures that all our classes behave exactly as expected.
- Con – We don’t always have power over both the interface and implementation, for example when we either import or export an interface externally.
- Otherwise, we may be extremely defensive by simply ruling out the use of interface delegates.
- Pro – This ensures the problem can never arise in this form, regardless of how any upstream interfaces might change.
- Con – Interface delegates can be very convenient!
- Or, rule out default method implementations which call a second method on the same interface.
- Pro – This is an arbitrary restriction which brings up other problems about how to maintain backwards compatibility on the interface.
- Con – Default method implementations are a conventional feature of both Java and Kotlin, so we can’t expect to be able to do this in all cases.
Personally, I use delegates conservatively – only when there is a lot of boiler plate cluttering the class body –
and remember to check for any delegated implementations that I need to validate when I write a default method.
TechTalk is our engineering series where members of the Epimorphics team share practical lessons from building and operating production data services. Have you encountered similar behaviour in Kotlin or another language?
We’d be interested to hear about the edge cases and engineering lessons you’ve discovered in production. Share your thoughts in the comments or get in touch with the Epimorphics team.

