Abstract classes, interfaces, and sealed interfaces — let’s help Santa find the best implementation of the verification algorithm.

Magdalena Thomas
Jit Team
Published in
7 min readDec 21, 2023

Do you remember any situation that occurred, during your developer’s career, in which you were completely unable to find a suitable solution of a problem? And can you remember circumstances, when there were so many solutions that it was hard to decide which one is the best? If yes — welcome to our Kotlin developer’s world! (Yeah, I admit, this is a bit over the top ;))

Some time ago, during new features development — I had to face that kind of situation. I encountered a problem and, a lot of options for solving it appeared in my mind. Which one was the best? Let’s have a closer look!

Introduction

The feature has been changed for the purpose of writing this article, therefore let’s move to the Arctic Circle, where the Christmas preparations are underway. Santa, together with his elves, is verifying whether a child has been good this year, and whether it truly deserves for gifts. The verification is performed asynchronously, and the result is saved in a database (the result is not important now). The results of this process have three common properties — identity of a given child, the start date of the year, and the information on who placed the order (elf’s name and surname). Additionally, positive verification also includes the number of gifts and the address where they should be delivered. However, the negative verification contains an additional field with the reason for the rejection (e.g. the child didn’t make the bed or didn’t take out the trash).

Assumptions

We must implement a getVerification() function which returns the data associated with the verification, regardless of its result. What is more, we should take it into the consideration , that our solution must be generic and open for further modification. Moreover, the returned data must be encapsulated in data classes — the object in Kotlin which allows immutable instance creation. Furthermore, methods such equals(), toString(), hashCode() are made out of the box.

First solution

There is one object for all results of the verification. Common fields for both results are marked as non-nullable while properties which are dedicated for postive/negative verification are marked as nullable (in Koltin we use question mark next to the property type). What is more, in this object there is an enum field called result which tell us the information about the result of the verification.

data class Verification(
val userId: String,
val verifiedAt: String,
val verifiedBy: String,
val rejectionReason: String?,
val numberOfGifts: Int?,
val address: String?,
val result: Result,
){
enum class Result { POSITIVE, NEGATIVE }
}

As you can see above, this solution seems to be the easiest one, but it is not good from the engineering point of view. First of all, if we use this solution, we cannot provide the data consistency due to the fact, that we do not have control under setting fields — e.g. during negative verification, somebody could set a field associated with number of gifts (at the end, this field is marked as nullable, which means that it could, but does not have to be null). Secondly, saving always nullable field (in positive verification rejectionReason will be empty, while in negative numberOf Gifts or address must be null) sounds like a waste of database memory and resources used in object validation later on. Let’s look for other solutions.

Developing the previous solution, we have already learned that we should divide the object into dedicated instances. That is the reason why we create one common object called Verification and PositiveVerification, NegativeVerification, as child objects.

Second solution

In the second solution, we will investigate the details regarding the use of abstract class as a representation of a common object.

abstract class Verification { 
abstract val userId: String
abstract val verifiedAt: String
abstract val verifiedBy: String
}

class NegativeVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val rejectionReason: String
) : Verification()

class PositiveVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val numberOfGifts: Int,
val address: String
) : Verification()

The common properties placed in an abstract class must be preceded by a word abstract, because there are not initialized. Then in a child object they should be overridden. What is more, in abstract class, we can initialize a field which will be then available from the child class level (see the example below).

abstract class Verification {
val type: String = "Christmas 2023"
abstract val userId: String
abstract val verifiedAt: String
abstract val verifiedBy: String
}

class NegativeVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val rejectionReason: String
) : Verification()

fun verifyChild(): Verification {
/.../
val negativeVerification = NegativeVerification("user1", "2023–12–06", "elf2", "didn't clean up the toys")
logger.info { "Verification type: ${negativeVerification.type}" }
/.../
}

The result of verifyChild(): Verification type: Christmas 2023

We should also remember that in an abstract class, the initialized fields are final by default, so when we want to make this filed modifiable in child classes, we should add the open keyword before it (look below).

abstract class Verification {
open val type: String = "Christmas 2023"
abstract val userId: String
abstract val verifiedAt: String
abstract val verifiedBy: String
}

class NegativeVerification(
override val type: String = "The worst christmas ever",
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val rejectionReason: String
) : Verification()

Summing it up, this solution is better than previous one, because it ensures data consistency. The common fields are clearly split up and could be initialized in base class. Is there any disadvantage to this approach? Unfortunately, the best-known property of abstract class is that we can inherit only from one class. And this is the biggest disadvantage of this solution. How can we improve it? Please take a look at solution number 3!

Third solution

Let’s replace the abstract class with an interface. Thanks to that we will make our objects open to further modification — as we know, each object can implement an infinite number of interfaces ensuring polymorphism.

interface Verification { 
val userId: String
val verifiedAt: String
val verifiedBy: String
}

class NegativeVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val rejectionReason: String
) : Verification

class PositiveVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val numberOfGifts: Int,
val address: String
) : Verification

fun verifyChild(): Verification {
/.../
return PositiveVerification("user1", "2023–12–06", "elf2", 4, "St.Thomas 34, London")
}

When analysing the above example, we can clearly see that as it is in inheritance in abstract class, fields from the interface must also be overwritten in their implementations. The disadvantage of this solution is the fact that fields cannot be initialized in interfaces — however we can ignore it since this was not our requirement to have such feature. Can we improve this solution in any way? Yes! Look at sealed classes and interfaces definitions.

Fourth solution

When we add keyword sealed in the abstract class or interface declaration, we will have gained a full control of child objects. Moreover, this declaration could be placed outside the abstract class/interface but must be situated in the same file as sealed parent. Above I present an example with a usage of sealed interface.

sealed interface Verification { 
val userId: String
val verifiedAt: String
val verifiedBy: String

data class NegativeVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val rejectionReason: String
) : Verification

data class PositiveVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val numberOfGifts: Int,
val address: String
) : Verification

}

Similarly, the second solution presented in this article with the of an abstract class can be replaced with a sealed abstract class. As before, common fields should be marked as abstract and overwritten in child classes. However, the result of this solution will still be the same as in the approach we have already abandoned, we only gain control over creating child objects.

sealed class Verification { 
abstract val userId: String
abstract val verifiedAt: String
abstract val verifiedBy: String

data class NegativeVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val rejectionReason: String
) : Verification()

data class PositiveVerification(
override val userId: String,
override val verifiedAt: String,
override val verifiedBy: String,
val numberOfGifts: Int,
val address: String
) : Verification()

}

Conclusions

Using an implementation with sealed interface is the best solution that meets all of our requirements. First at all, creating dedicated objects for the appropriate verification result and separating common properties ensures data consistency. What is more, we can be sure that fields that should be filled in each option will not remain empty. Moreover, the use of interfaces makes our solution open to possible changes in the future, because such objects are easy to extend. Thanks to the use of sealed interfaces, we have control over the child objects creation — it is thus guaranteed that nowhere else in the program, the third type of verification result will be created e.g. resulting in a circumstance in which naughty children will get gifts — such an implementation can only be created in the same file as the sealed interface.

To sum up, the sealed interface is a perfect solution when you want to create a certain number of objects that implement a given interface while making them open to further modifications.

--

--