Composition over Inheritance: My refactoring recipe

Composition over Inheritance: My refactoring recipe

"Composition over Inheritance": Mostly agreed upon, often neglected.

"DRy", "KisS", "CLEAn", "SoLid". We all read these almost meaningless terms being thrown around in our code reviews. Whilst just mentioning some acronym never really seemed like a convincing argument to me, the intention of the reviewer was mostly always clear: The code, currently under review, in the eyes of the reviewer, seems to violate some seemingly universally agreed upon idiom, and the reviewer is concerned. Sometimes the concern is really valid: If you just copy and paste 20 lines of code with lots of magic values, modifying one line, you're likely to have a bad time maintaining this. Even though just casually placing a "DRY" in the code-review is not good practice, it would seem fair to me.

One of such seemingly universally agreed upon idioms seems to be "Composition over Inheritance" and During my entire career at JetBrains, I never heard any engineer even remotely trying to argue with this. The tradeoffs of what most devs would call 'composition' over 'inheritance' seem to be very well understood, and in most cases it is very much advisable not use abstract classes! Let me even drop the first very controversial statement early on in this post:

If you are a young engineer, you should never use any abstract class unless you are very, very sure of what you are doing. Just do not. Pretend that your favourite language (since you're reading this, Kotlin, I assume), does not have such a language feature.

There are some good exceptions, and I will show two cases where I, personally, like using abstract classes.

Expectation vs Reality

The biggest downsides of abstract classes are

  • Bad code re-use-ergonomics: You can only inherit code from one superclass, therefore, code sharing can only be 'linear' which is very limiting, often leading to code duplication or bad abstractions.

  • Missing scopes and clear boundaries / tight coupling: Sharing logic with abstract classes mostly leads to complex class hierarchies. Understanding small pieces of code in isolation becomes impossible because every unit of code depends (and potentially interacts) with all other units. To understand one class, you need to understand all classes in the hierarchy. To understand one function, you need to read and understand all private states this function has access to and all other private/protected functions it can call.

  • Difficulty in Testing: Since code is tightly coupled and no isolated units of code even exist, it can become quite hard to test in abstract base classes.

  • ...

And even though many more problems can be listed against using abstract classes, the reality is (at least for me): Real life code is filled with classes like AbstractFoo or BaseXyz used for sharing code. I would like to complain even further: Most of the time I struggled hard to understand code, when most time was spent not writing code, but wondering 'where to extend or modify existing code', when frustration rose high: It was almost always some abstract class hierarchy, sharing logic.

Refactoring abstract classes: My recipe

Over the time of my career, I have encountered frustrations with such abstractions often enough that I am now following a pretty easy 'refactoring recipe' which I would like to share with practical code examples. Note: As with any other recipe you will find online, this one here is also influenced heavily by my own personal taste. However: As with other recipes: If you don't like it too sweet: Just put in less sugar! If you prefer more spice, use more chilies 🌶️.

Showcase: Kotlin's notion of a 'Compilation'

My previous job at JetBrains was to work on Kotlin Multiplatform, fixing many issues, working on the technology for several years until reaching an initial 'stable release'.

One of the hardest to work with parts in the build tooling code, which still gives me nightmares, was the code we had for modelling all the different kinds of 'compilations' we support in Kotlin.

Here is an absolutely horrendous diagram of this code before the refactoring:

Before

You can see: There is no clear structure (granted, maybe other ways of building diagrams would potentially yield some structure). Working on such class hierarchies is almost impossible. Debugging is a nightmare, fixing bugs takes crazy amounts of time.

By applying the recipe, which is shared in this blogpost, the code was refactored to look something like this:

After

Step 1: Analyse your class hierarchy

The example shown in the blogpost can be found here

GitHub: Example Code: Inheritance

GitHub: Example Code: Composition (Result)

In order to make this recipe practical, we will use an imaginary code example: In this example, let's pretend to have some entity as a GameState representing the current state of a Game. The abstract class hierarchy we would like to refactor is used for saving this GameState . The abstract class is called AbstractGameStateSaver

/**
 * Base class for saving the current state:
 * See the [save] method accepting a 'GameState' that can be persistet
 */
abstract class AbstractGameStateSaver {
    open fun save(state: GameState) {
        storeGameState(
            encodeGameState(state)
        )
    }

    /*
    To persist the "GameState" we need to encode the state to bytes and then store
    those bytes 'somewhere'
     */
    protected abstract fun encodeGameState(gameState: GameState): ByteArray
    protected abstract fun storeGameState(byteArray: ByteArray)
}

IntelliJ allows us to see a nice diagram of the AbstractGameStateSaver hierarchy. For the sake of simplicity in this example, the hierarchy is kept not too complicated here as well. However, the recipe will work equally well for more complex hierarchies! It's just harder to put more complex cases in the format of a blogpost!

Analysing the hierarchy will provide us with some kind of intuition of what code might be actually shared! It seems, just by looking at it that the game has four implementations of saving the current state. However, there might just be two dimensions:

  1. Format: Two formats for storing the Game state are supported: Json and Java Serializable

  2. Manual vs Auto save: It seems that the implementations somehow differentiate between saving manually (assumable by a player pressing some kind of save button) and automatic saving

Both dimensions are combined to offer four implementations

  • Manual Save, Json

  • Auto Save, Json

  • Manual Save, Java Serializable

  • Auto Save, Java Serializable

Step 2: Take the abstract base class and convert it to a non-abstract class

This means we will convert

abstract class AbstractGameStateSaver { to class GameStateSaver {

We can do this by also replacing all abstract functions with 'initial draft' interfaces that get passed to the GameStateSaver 's constructor. This saver requires some function to encode the current GameState to a ByteArray? Sure, let’s convert the encodeGameState function to an interface. Some implementation to write the encoded ByteArray to some persistent place is required? Sure, let’s create an interface!

// Before
abstract class AbstractGameStateSaver {
    open fun save(state: GameState) {
        storeGameState(encodeGameState(state))
    }

    protected abstract fun encodeGameState(gameState: GameState): ByteArray
    protected abstract fun storeGameState(byteArray: ByteArray)
}
// After
class GameStateSaver(
    private val encoder: Encoder, 
    private val writer: Writer,
){
    interface Encoder {
        fun encodeSaveState(gameState: GameState): ByteArray
    }

    interface Writer {
        fun writeSaveState(encoded: ByteArray)
    }

    fun save(gameState: GameState) {
        gameStateWriter.writeSaveState(
            encoder.encodeSaveState(gameState)
        )
    }
}

Step 3: Implement interfaces for the previously analysed use-cases

By also using composition!

For the sake of simplicity, all implementations only support writing to the Game state to a file (see that every class inherits from AbstractFileGameStateSaver 🎉) It should be simple to convert the code! Let’s look into this abstract class as well!

/**
 * Base Class for 'File based' Save States:
 * Will encode the [GameEntity] parts of the GameState and write it to the provided [file]
 */
abstract class AbstractFileGameStateSaver(private val file: File) : AbstractGameStateSaver() {

    /**
     * Easy: We know how to store the bytes in a file!
     */
    override fun storeGameState(byteArray: ByteArray) {
        file.writeBytes(byteArray)
    }

    /*
    This abstraction relies on somebody else providing the exact implementation
    of encoding the individual game entities
    */
    abstract fun encodeGameEntity(gameEntity: GameEntity): ByteArray

    /*
    This abstraction relies on somebody else being able to create the "SaveFileHeader"
     */
    abstract fun createSaveStateHeader(): SaveStateHeader

    open fun writeGameEntityHeader(
        stream: ByteArrayOutputStream, gameEntity: GameEntity, encoded: ByteArray
    ) {
        stream.write(ByteBuffer.allocate(4).also { buffer -> buffer.putInt(encoded.size) }.array())
    }

    open fun writeGameEntity(stream: ByteArrayOutputStream, gameEntity: GameEntity, encoded: ByteArray) {
        writeGameEntityHeader(stream, gameEntity, encoded)
        stream.write(encoded)
    }

    override fun encodeGameState(gameState: GameState): ByteArray {
        return ByteArrayOutputStream().also { out ->
            val header = createSaveStateHeader()
            writeGameEntity(out, header, encodeGameEntity(header))
            writeGameEntity(out, gameState.levelState, encodeGameEntity(gameState.levelState))
            writeGameEntity(out, gameState.playerState, encodeGameEntity(gameState.playerState))
        }.toByteArray()
    }
}

Oouf 😰: A typical example of an abstract class being an abstract class: A lot is going on and it might not be immediately clear, at first sight, what should be done here? Luckily, the override for storeGameState looks actually simple: The class requests a File in the constructor and will just store the encoded state to this file. The remaining code seems to belong to the Encoder part (for later).

This storeGameState can easily be isolated and extracted into an implementation of the previously established Writer

class FileGameStateWriter(
    private val file: File
) : GameStateSaver.Writer {
    override fun writeSaveState(encoded: ByteArray) {
        file.writeBytes(encoded)
    }
}

First interface implementation done ✅

The encoding part, however, still relies on some abstract methods. We therefore just create a Encoder implementation and will take the same approach: Convert each abstract function to a new interface which will be requested in the Encoder's constructor!

class GameStateEncoder(
    private val saveStateHeaderFactory: SaveStateHeaderFactory,
    private val gameEntityEncoder: GameEntityEncoder
) : GameStateSaver.Encoder {

    /**
     * The encoded Game State will include some kind of header. 
     * This information will include whether or not the save was done automatically
     * and when the save happened.
     */
    interface SaveStateHeaderFactory {
        fun createSaveStateHeader(): SaveStateHeader
    }

    /**
     * The [GameState] consists out of several parts ([GameEntity]). 
     * We will encode each part individually with this encoder
     */
    interface GameEntityEncoder {
        fun encodeGameEntity(gameEntity: GameEntity): ByteArray
        companion object
    }

    override fun encodeSaveState(gameState: GameState): ByteArray {
        return ByteArrayOutputStream().also { out ->
            val header = saveStateHeaderFactory.createSaveStateHeader()
            out.write(gameEntityEncoder.encodeGameEntity(header))
            out.write(gameEntityEncoder.encodeGameEntity(gameState.levelState))
            out.write(gameEntityEncoder.encodeGameEntity(gameState.playerState))
        }.toByteArray()
    }
}

Step 4: Repeat until all interfaces are fully implemented

In the current example, there are two more interfaces to implement: SaveStateHeaderFactory and GameEntityEncoder: For this blogpost I will only show implementing the GameEntityEncoder for json and Java Serializable.

We therefore copy the respective code, again, from the abstract class into an implementation of the previously created GameEntityEncoder.

// Before
abstract class AbstractJsonFileGameStateSaver(file: File) : AbstractFileGameStateSaver(file) {
    override fun writeGameEntityHeader(
        stream: ByteArrayOutputStream, gameEntity: GameEntity, encoded: ByteArray
    ) {
        stream.write("json".encodeToByteArray())
        super.writeGameEntityHeader(stream, gameEntity, encoded)
    }

    override fun encodeGameEntity(gameEntity: GameEntity): ByteArray {
        return gameEntity.encodeAsJson().encodeToByteArray()
    }
}
// After
object JsonGameEntityEncoder : GameStateEncoder.GameEntityEncoder {
    override fun encodeGameEntity(gameEntity: GameEntity): ByteArray {
        // Assume we have the encodeAsJson implemented somewhere
        val jsonAsBytes = gameEntity.encodeAsJson().encodeToByteArray()

        return ByteArrayOutputStream().also { out ->
            out.writeString("json")
            out.writeInt(jsonAsBytes.size)
            out.write(jsonAsBytes)
        }.toByteArray()
    }
}

Note: Just by reading the AbstractJsonFileGameStateSaver it is very hard to understand how the actual encoding would work. This is because of the opaque relation between writeGameEntityHeader and encodeGameEntity . Such hard to reason about code is a widespread symptom of abstract class hierarchies (and was purposely written this way)

Step 5: Refine the new created interfaces and composable implementations

Refine the interfaces and their implementations, provide nice discoverable APIs, and provide factory functions!

For this step, companion objects will be used in Kotlin to make certain APIs discoverable and uppercase factory functions to create actual instances of our GameStateSaver. Again: If you do not like it sweet, feel free to put less sugar into the cake!

Here is an example, making the previously implemented FileGameStateWriter file private and providing some discoverable API by extending the interfaces companion:

fun GameStateSaver.Writer.Companion.file(file: File): GameStateSaver.Writer = FileGameStateWriter(file)

private class FileGameStateWriter(
    private val file: File
) : GameStateSaver.Writer {
    override fun writeSaveState(encoded: ByteArray) {
        file.writeBytes(encoded)
    }
}

Step 6: Declare the final entities

In Step 1, we were able to see that we had four implementations of the GameStateSaver . To finish our refactoring, we need to declare 4 such GameStateSaver as well.

Previously, these four implementations were just this: Implementations of the respective base classes. This was actually quite simple, yet very inflexible:

/**
 * Implementation of a save state which is
 *  - file-based
 *  - uses json as encoding
 *  - creates a header with current time and 'isAutoSave = true'
 */
class AutoSaveJsonFileGameStateSaver(file: File) : AbstractJsonFileGameStateSaver(file) {
    override fun createSaveStateHeader(): SaveStateHeader {
        return SaveStateHeader(isAutoSave = true, saveTime = Clock.System.now())
    }
}

/**
 * Implementation of a save state which is
 *  - file-based
 *  - uses java.io.Serializable as encoding
 *  - creates a header with current time and 'isAutoSave = true'
 */
class AutoSaveJavaSerializableFileGameStateSaver(file: File) : AbstractJavaSerializableFileGameStateSaver(file) {
    override fun createSaveStateHeader(): SaveStateHeader {
        return SaveStateHeader(isAutoSave = true, saveTime = Clock.System.now())
    }
}

/**
 * Implementation of a save state which is
 *  - file-based
 *  - uses json as encoding
 *  - creates a header with current time and 'isAutoSave = false'
 */
class ManualSaveJsonFileGameStateSaver(file: File) : AbstractJsonFileGameStateSaver(file) {
    override fun createSaveStateHeader(): SaveStateHeader {
        return SaveStateHeader(isAutoSave = true, saveTime = Clock.System.now())
    }
}


/**
 * Implementation of a save state which is
 *  - file-based
 *  - uses java.io.Serializable as encoding
 *  - creates a header with current time and 'isAutoSave = false'
 */
class ManualSaveJavaSerializableFileGameStateSaver(file: File) : AbstractJavaSerializableFileGameStateSaver(file) {
    override fun createSaveStateHeader(): SaveStateHeader {
        return SaveStateHeader(isAutoSave = false, saveTime = Clock.System.now())
    }
}

Now we can just compose our instance using our newly introduced building blocks. To do so, the following code will use uppercase factory functions:

fun AutoSaveJsonGameStateSaver(file: File): GameStateSaver {
    return GameStateSaver(
        gameStateWriter = GameStateSaver.Writer.file(file),
        encoder = GameStateEncoder(
            gameEntityEncoder = GameEntityEncoder.javaSerialization,
            saveStateHeaderFactory = SaveStateHeader.factory(
                isAutoSave = true
            ),
        ),
    )
}

fun AutoSaveJavaSerializableGameStateSaver(file: File): GameStateSaver {
    return GameStateSaver(
        gameStateWriter = GameStateSaver.Writer.file(file),
        encoder = GameStateEncoder(
            gameEntityEncoder = GameEntityEncoder.json,
            saveStateHeaderFactory = SaveStateHeader.factory(
                isAutoSave = true
            ),
        ),
    )
}

fun ManualSaveJsonSaveGameStateSaver(file: File): GameStateSaver {
    return GameStateSaver(
        gameStateWriter = GameStateSaver.Writer.file(file),
        encoder = GameStateEncoder(
            gameEntityEncoder = GameEntityEncoder.json,
            saveStateHeaderFactory = SaveStateHeader.factory(
                isAutoSave = false
            ),
        )
    )
}

fun ManualSaveJavaSerializableGameStateSaver(file: File): GameStateSaver {
    return GameStateSaver(
        gameStateWriter = GameStateSaver.Writer.file(file),
        encoder = GameStateEncoder(
            gameEntityEncoder = GameEntityEncoder.json,
            saveStateHeaderFactory = SaveStateHeader.factory(
                isAutoSave = false
            ),
        )
    )
}

At this point, the positive as well as the negative points about this approach should be rather clear

  • The composition is explicit and more verbose

  • The composition can be easily extended

    Just imagine adding support for writing theGameStateto an http server. A nightmare to extend the abstract classes, just one more implementation of theWriterinterface using the composition

  • The composition can re-use components very easily

    NewGameStatewriter instances can be declared freely, re-using any arbitrary building block which currently exists.

  • Any source file in the composition can be understood on its own!

If you're a Kotlin application developer, the above code might remind you of writing user interfaces withCompose. While 'Compose the UI framework' leverages many more advanced techniques, the concept of 'composition' is contributing heavily to the ergonomics of Compose and likely fundamental to its success.

Reasons to use abstract classes

There might be actually many good reasons for using abstract classes, I guess? But as promised at the beginning of this blogpost, I now list the two cases where I, personally, use abstract classes:

junit 4 (a.k.a for historical reasons)

Do I need to say much more? My daily job is to contribute to either kotlin.git or intellij.git. Both projects already accumulated a lot of history, and many fundamental tests and test infrastructures rely on junit 4 therefore, naturally, share plenty of testing code using inheritance. This is legacy and might change, maybe, but I actually think that writing tests using inheritance is kind of ok.

interface vs abstract class

Sometimes, rarely, but sometimes, I actually want to enforce that an implementor of a given contract (represented either as interface or abstract class) cannot implement another such contract. In this case, using abstract classes seems like a perfect solution to me:

sealed interface Result
interface Success: Result
interface Failure: Result

class WeirdResult: Success, Failure // 😨
sealed class Result
abstract class Success: Result
abstract class Failure: Result

class SuccessImpl: Success() // Cannot also implement Failure()!
class FailureImpl: Failure() // Cannot also implemente Success()!

Wrap up: Step by Step, The recipe

  • Step 1: Analyse your class hierarchy

    Use some code analysis tool of your choice (I recommend IntelliJ) and get some insight into the structure of the abstract class hierarchy:

    • Look for 'all implementations of this given abstract class'

    • Try to find some structure in the hierarchy.

  • Step 2: Take the abstract base class and convert it to a non-abstract class

    • Drop the abstract keyword in the class declaration

    • Replace all abstract functions with interfaces

    • Inject implementation of those interfaces in the constructor of the class

  • Step 3: Implement interfaces for the previously analysed use-cases

    • Go to direct subclasses of the recently converted abstract class and try to convert the override fun functions into implementations of the new interfaces

    • If the subclass was also declared abstract, then convert those abstract functions into interfaces again

  • Step 4: Repeat until all interfaces are fully implemented

    Recursively apply Step 3, work your way up to the actual final implementations, copying code from the override fun functions into implementations of composable interfaces.

  • Step 5: Refine the new created interfaces and composable implementations

    Stir, Stir Stir 🧑‍🍳, the recently created interfaces and implementations need some refinements. Find good names, find good abstractions, put some love into the recently refactored code!

  • Step 6: Declare the final entities

    Provide factory functions to allow easy constructions of the 'production' instances. The code should feel declarative and 'composable'.