lastminute.com logo

Technology

Modular functional programming with Kotlin

emanuele_colombo
emanuele colombo

Functional programming in Kotlin could be very easy on the small, but how we can build a real-world application, splitting the code in some indipendent components and make them easily testable? Let's see a possible approach in order to solve this problem.


While introducing myself to functional programming, I needed to understand how all the FP principles connect in order to obtain a production-ready real-life application.

As a backend Java/Kotlin developer, I was missing the structure an application should have in order to be pragmatically functional, without losing all the advantages I’ve got right now in the object oriented world.

It seems very difficult to find this kind of information on the web, or to find out open source codebases in order to take inspiration from, especially in the Kotlin world. So I started studying and experimenting in order to understand how FP applications are structured and composed using other technologies and languages, looking mainly in the Scala world, where the FP community is more mature than the Kotlin one, trying to understand what mysterious definitions like “tagless final” or “free monad” meant.

At the end, what I’ve found out combines hints coming from a very good talk by Paco Estevez, mixed with the work John De Goes is doing with ZIO on Scala and with the lessons from the book “Functional and Reactive Domain Modeling” by Debasish Ghosh.

Why modules

In order to make an application “modular”, you need to split the code in elements that we can call “modules”; in the FP context a simple definition of module could be just “group of functions”.

While trying to find out how to structure a FP backend application with Kotlin, I was looking for a way to create modules in order to be able to test the functions in isolation. Test them in isolation has two meanings:

  • be able to write tests that stress only the specific functions inside the module and not the whole application;
  • when a function depends on functions from other modules, be able to replace the dependency with a test implementation or a mock/fake.

So, if you have some code that relies on external services (e.g. in a microservices architecture) or on a database, you could replace the production code that calls the external service or that connects to the database with a test implementation.

How to define a module

So, let’s talk about the code. In order to define a module, you can just define an interface; I use the “Has” prefix here, it will became clearer later:

interface HasGeometricalService {

    fun circleArea(radius: Double): Double

    fun rectangleArea(height: Double, width: Double): Double

}

Then we can implement the functions exposed by the interface, still using an interface as a container:

interface LiveGeometricalService : HasGeometricalService {

    override fun circleArea(radius: Double): Double =
        PI * radius.pow(2)

    override fun rectangleArea(height: Double, width: Double): Double =
        height * width

}

also, in this interface we can place all the “private” functions you need, that basically means all the functions you don’t need to expose/export outside this module. Just an example:

interface LiveGeometricalService : HasGeometricalService {

    override fun circleArea(radius: Double) =
            PI * powerOfTwo(radius)

    override fun rectangleArea(height: Double, width: Double) =
            height * width

    fun powerOfTwo(n: Double) =
            n.pow(2)

}

That’s it, the module is complete. But, what happens when a module needs to call a function exposed in another module?

Module’s dependencies

Now, let’s implement a new module that uses the functions exposed by the previous one. Suppose our application is going to get the radius of a circle and the width/height of a rectangle from the user and compute the total area. We define the interface like before:

interface HasTotalAreaService<ENV> {

    fun ENV.totalArea(radius: Double, width: Double, height: Double): Double

}

There is something different than before in this definition; the interface now defines a generic type ENV and the totalArea function is defined as an extension of it. In this way, we basically say that this method will require some other module in order to make it work, but we are not saying what specific module it will need as it will depend upon the implementation. Also, if you prefer a coherent way to define all your module interfaces, you can use this format for all your modules, as the need for an external dependency is related to the implementation and not on the definition. We can rewrite all the previous code in this way:

interface HasGeometricalService<ENV> {

    fun ENV.circleArea(radius: Double): Double

    fun ENV.rectangleArea(height: Double, width: Double): Double

}

interface LiveGeometricalService<ENV> : HasGeometricalService<ENV> {

    override fun ENV.circleArea(radius: Double) =
            PI * powerOfTwo(radius)

    override fun ENV.rectangleArea(height: Double, width: Double) =
            height * width

    fun powerOfTwo(n: Double) =
            n.pow(2)

}

interface HasTotalAreaService<ENV> {

    fun ENV.totalArea(radius: Double, width: Double, height: Double): Double

}

Now, we can proceed with the implementation of the TotalAreaService module:

interface LiveTotalAreaService<ENV> : HasTotalAreaService<ENV>
    where ENV : HasGeometricalService<ENV> {

    override fun ENV.totalArea(radius: Double, width: Double, height: Double): Double {
        val c = circleArea(radius)
        val r = rectangleArea(height, width)
        return c + r
    }

}

When implementing the module, we keep the environment type (ENV) generic, without resolving it, but we introduce a constraint on it, expressed by the where clause. In this code the where clause stands for “the environment should provide the geometrical service”; if the module requires more than one dependency, you can just list all your dependencies with the syntax:

interface LiveModule<ENV> : HasModule<ENV>
    where ENV : HasService1<ENV>,
          ENV : HasService2<ENV>,
          ENV : HasService3<ENV>

Since the functions implemented inside the module are defined as extensions, all the functions provided by the environment are directly accessible, like the circleArea and rectangleArea used in the example.

Wiring them together

Once we have implemented all our modules, we have to wire them together in order to resolve all the dependencies. We are now going to use a little trick in order to define and instantiate a concrete environment where all our functions are available and every dependency is satisfied.

interface MyEnvironment :
    HasTotalAreaService<MyEnvironment>,
    HasGeometricalService<MyEnvironment>

val myEnvironment: MyEnvironment = object : MyEnvironment,
    HasTotalAreaService<MyEnvironment> by object : LiveTotalAreaService<MyEnvironment> {},
    HasGeometricalService<MyEnvironment> by object : LiveGeometricalService<MyEnvironment> {}
{}

First, we define the type of our environment, by creating an interface that basically defines all the modules we are going to wire together. Doing this, we know that the type MyEnvironment would be enough to satisfy all the modules dependencies. Then, we create an instance of the environment combining, for every module defined for the environment, the corresponding implementation we are going to use, taking advantage of the delegation pattern. The little trick I was mentioning before is the self-referential type definition: actually, the MyEnvironment definition contains references to itself. Since this type of definition is allowed in Kotlin, we can make use of it in order to express this composition.

As a side note, you don’t have to create a single environment with all the modules of your application; you could just create different environments composing only the services needed for every specific use case.

Testing

Given this modules structure, it becomes very easy to write unit tests of a module or integration tests between modules just composing the modules under test with their dependencies; and we can choose to use the real production implementation or provide a test one for every single dependency.

So, if we want to unit test the LiveTotalAreaService from the previous example, we can just provide a test implementation of the HasGeometricalService:

interface TestGeometricalService<ENV> : HasGeometricalService<ENV> {

    override fun ENV.circleArea(radius: Double): Double = 5.0

    override fun ENV.rectangleArea(height: Double, width: Double): Double = 8.0

}

And, for testing purposes, we can wire the corresponding test environment:

val testEnv: MyEnvironment = object : MyEnvironment,
    HasTotalAreaService<MyEnvironment> by object : LiveTotalAreaService<MyEnvironment> {},
    HasGeometricalService<MyEnvironment> by object : TestGeometricalService<MyEnvironment> {}
{}

Conclusions

Creating a functional application with Kotlin that is both modular and easy to test is possible, even if there aren’t so many examples on the web. I hope this post will be helpful to anyone approaching the world of functional programming with Kotlin.

There are some topics that I didn’t mentioned here, like the side effects handling and the program polymorphism with generic effects; things that are provided with the famous final tagless approach. These can also be provided with this approach (thanks to Arrow), but maybe I’ll talk about them in another post.


About emanuele colombo

emanuele_colombo
Software Engineer

Emanuele is a Senior Software Engineer with a strong background in telecommunication networks. He is passionate about pragmatic Functional Programming and is particularly interested in tackling challenges like scaling software development and managing complexity through tools such as Domain-Driven Design (DDD), modularisation, clean code, and CI/CD.


Read next

React Universe 2024

React Universe 2024

fabrizio_duroni
fabrizio duroni
sam_campisi
sam campisi

Let's dive into the talks from React Universe 2024 that stood out to us the most and share the key insights we gained. From innovative debugging tools to cross-platform development strategies, we’ll walk you through what we found valuable and how it’s shaping our approach to React and React Native development. [...]

Tech Radar As a Collaboration Tool

Tech Radar As a Collaboration Tool

rabbani_kajamohideen
rabbani kajamohideen

A tech radar is a visual and strategic tool used by organizations to assess and communicate the status and future direction of various technologies, frameworks, tools, and platforms. [...]