Home | KMPizza
What is Kotlin Multiplatform? In short, it’s the future of mobile development. You don’t believe me? Let me try to persuade you: Kotlin Multiplatform is still young, but it’s a very promising technology. It allows developers to share parts of code between different platforms, whether it’s your backend, or an iOS app, or an Android app, or even a web or desktop. Kotlin will find its way anywhere.
It’s particularly great for mobile developers, because it can help us leverage the best of Kotlin to quickly build apps that look amazing both on iOS and Android. You’ll be able to share lots of code between the two platforms and focus on making UI appealing to the user! Moreover, you can build a backend in Kotlin that will complement your KMM app and make it even more sustainable.
I’ve spent a lot of time researching on KMP, struggling through gradle files and dependencies, building apps for work and for myself and would love to share my experience with everyone else who’s interested.
So stock up with pizza ingredients and let’s explore the exciting world of KMP together! 🤓
This repository will accompany each step of the blog: KMPizza Repo

This project is inspired by:
KaMPKit
MultiplatformPlayground
PeopleInSpace
Now that we have a backend for the recipes we’ll use KMM power to implement some shared layers, such as networking, local app storage and even viewmodels.
Let’s start with networking, where we will use all the functionality from our backend.
First, add Ktor Client dependencies to our project. Yes, we’ll be using the same Ktor we used for our backend, but it’s Client functionality this time.
Add a Common
object with necessary dependencies to your Versions.kt
:
object Common {
const val KTOR_CLIENT_CORE = "io.ktor:ktor-client-core:$KTOR_VERSION"
const val KTOR_CLIENT_JSON = "io.ktor:ktor-client-json:$KTOR_VERSION"
const val KTOR_LOGGING = "io.ktor:ktor-client-logging:$KTOR_VERSION"
const val KTOR_CLIENT_SERIALIZATION = "io.ktor:ktor-client-serialization:$KTOR_VERSION"
}
Alongside with the client dependency and additional features like logging
and serialization
we add engine dependencies for our platform sides:
object Android {
const val KTOR_CLIENT = "io.ktor:ktor-client-android:$KTOR_VERSION"
const val KTOR_OKHTTP = "io.ktor:ktor-client-okhttp:$KTOR_VERSION"
}
object iOS {
const val KTOR_CLIENT = "io.ktor:ktor-client-ios:$KTOR_VERSION"
}
Now implement these dependencies in build.gradle.kts
in your shared
module (not backend!). Modify the corresponding sourceSets as follows:
val commonMain by getting {
dependencies {
// Ktor
implementation(Versions.Common.KTOR_CLIENT_CORE)
implementation(Versions.Common.KTOR_LOGGING)
implementation(Versions.Common.KTOR_CLIENT_JSON)
implementation(Versions.Common.KTOR_CLIENT_SERIALIZATION)
}
}
val androidMain by getting {
dependencies {
implementation(Versions.Android.KTOR_CLIENT)
implementation(Versions.Android.KTOR_OKHTTP)
}
}
val iosMain by getting {
dependencies {
implementation(Versions.iOS.KTOR_CLIENT)
}
}
Also add serialization
plugin to the shared
module plugins
, so that we can deserialize data from network responses into data classes:
plugins {
kotlin("multiplatform")
kotlin("native.cocoapods")
id("com.android.library")
kotlin("plugin.serialization") version Versions.KOTLIN_VERSION
}
Sync your project files.
Now, let’s create our Ktor client.
First, in the shared module in commonMain
create a new package api
and add a KtorApi
interface:

Here we declare the Ktor client and the methods which we’ll use for all api calls.
interface KtorApi {
val client: HttpClient
fun HttpRequestBuilder.apiUrl(path: String)
fun HttpRequestBuilder.json()
}
With apiUrl
we’ll set the request path and with json
we’ll tell the client to receive data in form of json.
Then we add a KtorApiImpl.kt
inext to KtorApi.kt
and write an implementation:
class KtorApiImpl() : KtorApi {
val prodUrl = "https://enigmatic-sands-01782.herokuapp.com/" [1]
override val client = HttpClient {
install(JsonFeature) { [2]
serializer = KotlinxSerializer()
}
}
override fun HttpRequestBuilder.apiUrl(path: String) {
url {
takeFrom(prodUrl) [3]
encodedPath = path
}
}
override fun HttpRequestBuilder.json() {
contentType(ContentType.Application.Json) [4]
}
}
[1] For now we hardcoded the backend urls, later we’ll see how to manage them in build configuration
[2] We’ll need to serialize and deserialize our objects to receive and send them to our backend, therefore we use kotlinx serializer feature, that we imported earlier
[3] Specify the url that should be used to fetch data: it consists of the base production url and the following endpoint path
[4] Specify the content type for all requests, here we are using json
Now let’s create our actual RecipesApi
.
Your commonMain
should look like this by now:

RecipeApi
will extend the KtorApi
and contain the recipe endpoint:
class RecipesApi(private val ktorApi: KtorApi) : KtorApi by ktorApi {
companion object {
const val RECIPES_BASE_URL = "recipes"
}
}
Also add all the necessary requests for our app:
suspend fun getPizza(): String {
return client.get {
apiUrl("pizza") [1]
}
}
suspend fun getRecipes(): List<RecipeResponse> {
return client.get { [2]
apiUrl(RECIPES_BASE_URL)
}
}
suspend fun getRecipe(id: Long): RecipeResponse {
return client.get {
apiUrl("$RECIPES_BASE_URL/$id")
}
}
suspend fun postRecipe(recipe: Recipe): Long [3] {
return client.post {
json() [4]
apiUrl(RECIPES_BASE_URL)
}
}
[1] For our test endpoint we don’t use the recipes url: instead we call the pizza
url and should get a String
in return
[2] Use the GET
function to receive data from backend as a List of RecipeResponses
[3] When we post a recipe we get an ID of type Long in return
[4] Don’t forget to specify the content type for your post request
As you can see, we just mirrored the requests that we already have in our backend api. We also have an option to upload pictures, but we’ll see how to do it after we’re done with basic text requests.
You may be wondering, why Recipe
and RecipeRepsonse
are unresolved. Well, we’ve created an entity for our model, but it was in another module, the backend
module.
To solve this problem, let’s copy that Entity.kt
file and put it into a model
folder in our shared
module. Now you can resolve the references in RecipeApi
.
Let’s remove the model
package from our backend
module and implement the shared
project there instead.
To do so, in shared build.gradle.kts
first add jvm()
to your targets:
kotlin {
android()
jvm()
. . .
}
Now, implement the shared project in the backend
build.gradle.kts
under dependencies
:
implementation(project(":shared"))
Sync your project files and fix the Exposed entities in your backend module to import corresponding classes from kmpizza.model
package.
Now we have our model only in one location, and it’s extremely convenient, isn’t it?
Finally, let’s create a remote data source, which we can later use alongside a local source to get appropriate data.
Create RecipeRemoteSource
class in shared/commonMain/remote
.

This class will receive recipesApi
as a parameter and use it to fetch recipes with getRecipes
from our RecipesApi
, as well as add a recipe with postRecipe
.
class RecipeRemoteSource(
private val recipesApi: RecipesApi
) {
suspend fun getRecipes() = recipesApi.getRecipes()
suspend fun postRecipe(recipe: Recipe) = recipesApi.postRecipe(recipe)
}
Easy as that, with the help of Ktor we’ve just added a networking layer to our shared KMM module. This will be the only location where we implement our api requests. You’ll see how to use them on platform sides in the following steps. However, we’re not done with the shared part. In the next step we’ll look into sharing a viewmodel layer.
So far we’ve tested our backend with images locally, now let’s move it to Heroku! But this time we’ll make things easier for us by deploying it directly on Heroku (without having to build the jar in Android Studio and deploying it manually after with heroku deploy:jar
).
First, add Procfile
to the project root
.

Write the following inside the Procfile
:
web: java -jar ./backend/build/libs/Backend.jar
It will tell Heroku to start a web dyno and deploy our backend.jar
file.
If you haven’t done so yet, add system.properties
to the project root as well.
It should contain the following settings, which will specify which module to build and what Java runtime to use:
java.runtime.version=11
rootProject.name = "backend"
Git add
and commit
both files: make sure system.properties
is committed, because it can be ignored!
Then do git push
.
Now run the following command in terminal:
heroku config:set GRADLE_TASK=":backend:build"
===== side note =====
(If you want to manually build a jar file again, you’ll need to run
heroku buildpacks:clear --remote <branch-name>
)
If you are deploying the heroku project on a new branch, don’t forget to specify it with –remote
postfix and also remember to install a postgres plugin there, for example like this:
heroku addons:create heroku-postgresql --remote <branch-name>
===== side note =====
We need to set a buildpack
for Heroku.
We’re using gradle, so we have to run the following command in terminal:
heroku buildpacks:set heroku/gradle
Finally, run to create a new release using this buildpack.
You will see a build failure 😱
Looks like we’ve forgotten something important.
If you look at the build logs, you’ll notice this error:
* What went wrong:
remote: A problem occurred configuring project ':buildSrc'.
remote: > Could not resolve all artifacts for configuration ':buildSrc:classpath'.
remote: > Could not resolve org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.21.
Let’s add the following line to our dependencies in backend gradle.build.kts
:
implementation(kotlin("stdlib"))
Run the git push heroku
command again.
It will take some time for the project to build, but finally you should see something like this:

Before serving your pizza directly from Heroku, you will need to set the necessary environment variables like this:
heroku config:set accessKey="MyAccessKeyForAWS"
heroku config:set secretKey="MySecretKeyForAWS"
heroku config:set bucketName="kmpizza"
heroku config:set region="eu-central-1"
Now, you can go to https://your-heroku-app-name.herokuapp.com/pizza and get a greeting from your backend, comfortably deployed on Heroku.
If you are hungry enough, then you can even try and add a pizza recipe with an image using Postman, just like we did previously when testing it locally!
Hey, we’re done with the backend for now! 🎉
We’ve build our basic backend for the app, so let’s move on to the world of shared KMM code!
In the next steps we’ll be using this backend while building the shared part of our future iOS and Android apps.
There is one thing that is currently missing in our recipes:
mouthwatering pictures of pizza with lots of cheese, like this one:

So let’s add pictures to our backend. We’ll use Amazon S3 Storage to upload and store images.
Attention: it requires creating an AWS account and binding in to your credit card, but we’ll only use free features, so no worries about having to pay. If you want to continue without images then just skip this part.
First, we need to register. Go to AWS portal and create an AWS account.
Verifying and binding your account to your credit card may take several days, then you’ll be able to start using AWS services.
When you’re verified, go to Amazon Services -> Storage -> S3

There create your first bucket, where you’ll store pizza pictures.


You can choose an AWS rregion for your bucket. To reduce latency choose the closest to you. Also do not choose “block public access”.

After your create a bucket it should appear in the list like this:

Remember your bucketName and region for future use in the app. In this case they are: bucketName: kmpizza
and region: eu-central-1
We’ll also need an accessKey
and a secretKey
.
Go to IAM Console.
And add a new user with programmatic access

Add permissions for S3 to this user

In stage 4 it should look like this:

When your user is created, copy your secretKey
and accessKey
accessKey: ******
secretKey: ******
Now that we have Amazon Storage all set up and ready, let’s get back to our backend module.
First, add AWS SDK to Versions.kt
:
const val AWS_VERSION = "2.17.102"
object Jvm {
...
const val AWS_JAVA_SDK = "software.amazon.awssdk:bom:$AWS_VERSION"
const val AWS_S3 = "software.amazon.awssdk:s3"
}
Then implement it in your backend build.gradle.kts
dependencies:
implementation(platform(Versions.Jvm.AWS_JAVA_SDK))
implementation(Versions.Jvm.AWS_S3)
Now let’s create a aws package and put it next to exposed in the storage directory:

Inside this package create a FileStorage
interface with a function to save files:
import java.io.File
interface FileStorage {
suspend fun save(file: File): String
}
Then create an AmazonFileStorage class that will implement this save function:
class AmazonFileStorage : FileStorage {
private val client: S3Client
private val bucketName: String = System.getenv("bucketName") [1]
init {
val region = System.getenv("region") [2]
val accessKey = System.getenv("accessKey")
val secretKey = System.getenv("secretKey")
val credentials = AwsBasicCredentials.create(accessKey, secretKey)
val awsRegion = Region.of(region)
client = S3Client.builder()
.credentialsProvider(StaticCredentialsProvider.create(credentials)) [3]
.region(awsRegion)
.build() as S3Client
}
override suspend fun save(file: File): String = [4]
withContext(Dispatchers.IO) {
client.putObject(
PutObjectRequest.builder().bucket(bucketName).key(file.name).build(),
RequestBody.fromFile(file)
) [5]
val request = GetUrlRequest.builder().bucket(bucketName).key(file.name).build()
client.utilities().getUrl(request).toExternalForm()
}
}
[1] Get the bucket name from environment variables
[2] Also get other settings to initiate an AWS S3 client: region (optional), access and secret Keys
[3] Use specified settings to build an S3Client
[4] Implement the save function from the FileStorage
Interface
[5] Use the object request to put a file into the AWS S3 bucket and get the source url
We want to use AWS functionality to upload images from the application when saving new recipes, so add FileStorage
to LocalSourceImpl
’s constructor like this:
internal class LocalSourceImpl(
private val fileStorage: FileStorage,
application: io.ktor.application.Application
) : LocalSource {
. . .
}
Just like with our local storage we want to use the AWS storage simply by injecting where we need it, so add one line to your KoinModule.kt
and modify the way we create LocalSourceImpl
by injecting the Amazon storage into it:
internal fun Application.getKoinModule() = module {
single<FileStorage> { AmazonFileStorage() }
single<LocalSource> { LocalSourceImpl(get(), this@getKoinModule) }
}
Let’s add a request to save images.
First, we’ll extend our recipes with a list of images that will show how delicious it is. Add a RecipeImage
data class to your Entity.kt
file
@Serializable
data class RecipeImage(
val id: Long = 0,
val image: String
)
We can create recipes with images in two steps:
1) use the previously created Recipe entity to post a recipe to the backend and receive the id of newly created Recipe
2) add pictures to it.
But to receive recipes with images directly from the backend whenever we need them we’ll introduce a RecipeReponse
entity:
@Serializable
data class RecipeResponse(
val id: Long = 0,
val title: String,
val ingredients: List<Ingredient>,
val instructions: List<Instruction>,
val images: List<RecipeImage>
)
Now we need to adjust our database. In a new image package create a RecipeImageTable
:

@Serializable
object RecipeImageTable : IntIdTable("recipeImages") {
val image = varchar("image", 1024)
val recipe = reference(
"recipe_id",
RecipeTable,
onDelete = ReferenceOption.CASCADE,
onUpdate = ReferenceOption.CASCADE
)
}
And a corresponding Entity:
class RecipeImageEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<RecipeImageEntity>(RecipeImageTable)
var image by RecipeImageTable.image
var recipe by RecipeEntity referencedOn RecipeImageTable.recipe
}
fun RecipeImageEntity.toRecipeImage() = RecipeImage(id.value.toLong(), image)
Finally, we need to adjust our RecipeEntity
by adding a recipeImage
property:
val recipeImages by RecipeImageEntity referrersOn RecipeImageTable.recipe
Add changing the converter function accordingly:
fun RecipeEntity.toRecipeResponse() = RecipeResponse(
id.value.toLong(),
title,
ingredients.map { it.toIngredient() },
instructions.map { it.toInstruction() },
recipeImages.map { it.toRecipeImage() })
Let’s use our newly created entities.
In your LocalSource
interface declare this function:
suspend fun saveImage(recipeId: Long, image: File)
Also change the return type of getRecipes
and getRecipe
:
suspend fun getRecipes() : List<RecipeResponse>
suspend fun getRecipe(recipeId: Long) : RecipeResponse
And in the LocalSourceImpl
write the following implementations:
override suspend fun saveImage(recipeId: Long, image: File) {
withContext(dispatcher) {
val imageUrl = fileStorage.save(image) [1]
transaction {
val recipe = RecipeEntity[recipeId.toInt()] [2]
RecipeImageEntity.new { [3]
this.image = imageUrl
this.recipe = recipe
}
}
}
}
[1] Save the image in S3 storage and get the image url in return
[2] Get the recipe from the database that the image should be attributed to
[3] Create a new recipe image entity and save a reference to the recipe
And don’t forget to change the return type of getRecipes
and getRecipe
:
override suspend fun getRecipes(): List<RecipeResponse> = withContext(dispatcher) {
transaction {
RecipeEntity.all().map { it.toRecipeResponse() }
}
}
override suspend fun getRecipe(recipeId: Long): RecipeResponse = withContext(dispatcher) {
transaction {
RecipeEntity[recipeId.toInt()].toRecipeResponse()
}
}
Also, we have to create a new image recipe table in the LocalSourceImpl
:
transaction {
SchemaUtils.createMissingTablesAndColumns(
RecipeTable,
IngredientTable,
InstructionTable,
RecipeImageTable
)
}
Finally, let’s add the routes to our api. Add this function to Routing
:
addRecipeImage(localSource)
And then implement it:
private fun Routing.addRecipeImage(localSource: LocalSource) {
post("/recipes/{recipeId}/recipeImage") {
val recipeId = call.parameters["recipeId"]!!.toLong()
var image: File? = null
call.receiveMultipart().forEachPart { [1]
when (it) {
is PartData.FileItem -> image = it.toFile() [2]
else -> Unit
}
it.dispose()
}
localSource.saveImage(
recipeId,
image ?: throw BadRequestException("image part is missing")
)
image?.delete()[3]
call.respond(HttpStatusCode.OK)
}
}
[1] We receive the image file from the client side as a multipart form data
[2] This will help us transform part data to a file type so that we can save the image data in our database. It is unresolved for now and still has to be implemented in the next step
[3] If an error happens we throw an exception and do not save the image file
As mentioned in [2] we still need to create the toFile
function that will help us transform the PartData.FileItem
to a File
. Create a utils package in our backend package and put an Extensions.kt
file there with the following function
import io.ktor.http.content.*
import java.io.File
:
fun PartData.FileItem.toFile() = File(originalFileName!!).also { file ->
streamProvider().use { input ->
file.outputStream().buffered().use { output ->
input.copyTo(output)
}
}
}
Now we can test uploading pictures to the recipes. First rebuild and make the project.
In the terminal set local environment variables according to your AWS settings.
export bucketName=kmpizza
export secretKey=******
export accessKey=******
export region=eu-central-1
export JDBC_DATABASE_URL=jdbc:postgresql:recipecollection?user=postgres
then run the backend locally as many times before:
java -jar ./backend/build/libs/Backend.jar -port=9000
Check with postman if you still have any recipes in your local database, otherwise add the pizza dough recipe again.
If your database is fresh you get you recipeId: 1
, otherwise choose the id of the recipe you want to add pictures to.
Now you can add an image to your recipe. Using that id:
http://localhost:9000/recipes/1/recipeImage

We get status 200 OK
in response. Let’s check if the recipe has an image now with getRecipes
. Indeed in our recipe we see a list of images now:
"images": [
{
"id": 1,
"image": "https://kmpizza.s3.eu-central-1.amazonaws.com/kmpizza.jpg"
}
]
Yay! 🥳
We’ve built our very functional backend to store delicious recipes with images. In the next step we’ll see how to publish our backend on Heroku automatically. Soon after that we’ll move on to using the backend we built within the shared code between iOS and Android.
The backend works locally and it’s already a big step forward, but how can we deploy it?
We’ll use Heroku for it.
If you don’t have a Heroku account you first need to sign up.
You’ll also need Heroku CLI installed, here are the instructions.
To make sure that the appropriate Java Runtime version is used while deploying on Heroku we need one more configuration . Add system.properties
file to the root of your project with the following lines:
java.runtime.version=11
rootProject.name = "backend"
After you’ve registered and installed Heroku CLI, you should be able to login from the terminal with the following command:
And then create a new application with
You’ll get the id
of your newly created app in return, like enigmatic-sands-01782

Then you will need to install a Heroku deploy plugin:
heroku plugins:install heroku-cli-deploy
Now deploy the jar created by shadowJar
under the name of our Heroku app with this command. Don’t forget to change the app name.
heroku deploy:jar ./backend/build/libs/Backend.jar --app enigmatic-sands-01782
It’ll take some time to upload the build

After a while you’ll see the upload succeeded:

Finally, add your remote heroku git source:
git remote add heroku git@heroku.com:enigmatic-sands-01782.git
In the future, every time you make changes you’ll need to update you remote like this:
Try opening https://enigmatic-sands-01782.herokuapp.com/pizza
Don’t forget to replace the Heroku app name with your own.
You’ll see an error.
Try investigating with heroku logs --tail
, you’ll find:
Caused by: org.koin.core.error.InstanceCreationException: Could not create
instance for [Singleton:'com.example.recipecollection.backend.localSource
LocalSource']
or
Caused by: java.lang.IllegalArgumentException: jdbcUrl is required with driverClassName.
It happens because we are missing a postgres database plugin which comes for free with Heroku. Let’s add it with
heroku addons:create heroku-postgresql
You’ll see something like this:

This has created your database url and should have automatically set the JDBC_DATABASE_URL
environment variable in heroku. To check it, you can either use
or
heroku run echo \$JDBC_DATABASE_URL
Let’s check out our app again with
No errors.
Go to https://enigmatic-sands-01782.herokuapp.com/pizza.
Awesome, now we can see our application serving pizza from Heroku!

Last time we tested the local data source with a simple request.
Now let’s add some real power to it.
First, declare a bunch of useful functions in the LocalSource
interface. These have to do with our future app’s functionality like adding recipes and corresponding ingredients and instructions.
internal interface LocalSource {
suspend fun getPizza(): String
suspend fun addIngredient(recipeId: Long, ingredient: Ingredient): Long
suspend fun addInstruction(recipeId: Long, instruction: Instruction): Long
suspend fun addRecipe(recipe: Recipe): Long
suspend fun getRecipes() : List<Recipe>
suspend fun getRecipe(recipeId: Long) : Recipe
}
Some of them return Long
, because we’ll add new entries to the database, which will assign an id
to each of them and return that id
as Long
.
Now implement these functions in LocalSourceImpl
:
override suspend fun addIngredient(recipeId: Long, ingredient: Ingredient) = withContext(dispatcher) {
transaction {
val recipe = RecipeEntity[recipeId.toInt()] [1]
IngredientEntity.new { [2]
name = ingredient.name
amount = ingredient.amount.toBigDecimal()
metric = ingredient.metric
this.recipe = recipe
}.id.value.toLong()
}
}
override suspend fun addInstruction(recipeId: Long, instruction: Instruction) = withContext(dispatcher) {
transaction {
val recipe = RecipeEntity[recipeId.toInt()]
InstructionEntity.new {
order = instruction.order
description = instruction.description
this.recipe = recipe
}.id.value.toLong()
}
}
override suspend fun addRecipe(recipe: Recipe) = withContext(dispatcher) {
withContext(dispatcher) {
val recipeId = transaction {
RecipeEntity.new {
title = recipe.title
}.id.value.toLong()
} [3]
recipe.ingredients.forEach{
addIngredient(recipeId, it)
} [4]
recipe.instructions.forEach{
addInstruction(recipeId, it)
}
recipeId
}
}
override suspend fun getRecipes() : List<Recipe> = withContext(dispatcher) {
transaction {
RecipeEntity.all().map { it.toRecipe() }
}
}
override suspend fun getRecipe(recipeId: Long): RecipeResponse = withContext(dispatcher) {
transaction {
RecipeEntity[recipeId.toInt()].toRecipeResponse()
}
}
These are basic functions that will fill up our database with recipes, ingredients and instructions.
[1] When creating an ingredient or instruction, we have to specify recipeId which this item belongs to and get the appropriate recipe
[2] Then we’ll create a new ingredient or instruction entity and attach it to our recipe
[3] When adding a new recipe to the database we first create a new recipe entry
[4] Then add all the instructions and ingredients to it
Now let’s bind those functions to our backend. Just like we did with getPizza, we’ll add some more routes for adding and getting recipes in Api.kt
:
internal fun Routing.api(localSource: LocalSource) {
pizza(localSource)
addRecipe(localSource)
getRecipes(localSource)
getRecipe(localSource)
}
Implement them in the same file:
private fun Routing.addRecipe(localSource: LocalSource) {
post("/recipes") {
val recipeId = localSource.addRecipe(call.receive())
call.respond(recipeId)
}
}
private fun Routing.getRecipes(localSource: LocalSource) {
get("/recipes") {
localSource.getRecipes().let {
call.respond(it)
}
}
}
private fun Routing.getRecipe(localSource: LocalSource) {
get("/recipes/{recipeId}") {
val recipeId = call.parameters["recipeId"]!!.toLong()
localSource.getRecipe(recipeId).let {
call.respond(it)
}
}
}
[1] When adding a Recipe we specify the Recipe data class as the body input and get a newly created recipe entity id from the database
[2] To get a recipe by id we need to lookup for the recipe with the id, that we specified in API route
One last thing before we test it: we want to be able to serialize our classes between the client and the server. To do so, we’ll need kotlinx.serialization
plugin. Let’s adjust our dependencies.
First, add this to the Versions.kt
:
const val KOTLIN_VERSION = "1.6.10"
In the backend
module add the serialization plugin to build.gradle.kts
:
plugins {
kotlin("jvm")
application
id("com.github.johnrengelman.shadow") version Versions.SHADOW_JAR_VERSION
kotlin("plugin.serialization") version Versions.KOTLIN_VERSION
}
You may also need to adjust the project build.gradle.kts
so that it matches the latest Kotlin version you specified, for example like so:
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.KOTLIN_VERSION}")
classpath("com.android.tools.build:gradle:7.1.0-alpha10")
}
Sync the project.
Then add @Serializable
annotation to all our model data classes like this:
@Serializable
data class Recipe(
val id: Long = 0,
val title: String,
val ingredients: List<Ingredient>,
val instructions: List<Instruction>
)
You may need to do a manual import:
import kotlinx.serialization.Serializable
Now let’s test this in action. Use Postman for it. Rebuild and make the backend again and run it locally as before:
export JDBC_DATABASE_URL=jdbc:postgresql:recipecollection?user=postgres
java -jar ./backend/build/libs/backend.jar -port=9000
Then try adding a Pizza Dough recipe with Postman. Send a POST
request to our API endpoint http://localhost:9000/recipes
. You can use the following body:
{
"title":"Pizza dough",
"ingredients": [
{
"name": "flour",
"amount": 2.0,
"metric": "cup"
},
{
"name": "water",
"amount": 1.0,
"metric": "cup"
},
{
"name": "flour",
"amount": 2.5,
"metric": "cup"
},
{
"name": "salt",
"amount": 1.0,
"metric": "teaspoon"
},
{
"name": "sugar",
"amount": 1.0,
"metric": "teaspoon"
},
{
"name": "dry yeast",
"amount": 7.0,
"metric": "gram"
},
{
"name": "olive oil",
"amount": 3.0,
"metric": "tablespoons"
}
],
"instructions":[
{
"order": 1,
"description": "Mix water, sugar and yeast. Let it froth for 10 minutes"
},
{
"order": 2,
"description": "Mix dough, salt, olive oil. Add water mixture from previous step."
},
{
"order": 3,
"description": "Knead the dough for 10 minutes"
}
]
}
We get 500 Internal Server Error
😱
Don’t be upset! We’ll find out why.
To help us investigate the cause we can install StatusPages
in Application.module()
and let it return the cause of the error like this :
install(StatusPages) {
exception<Throwable> { cause ->
call.respond(cause.toString())
}
}
Now if you rebuild the backend and try the same request again you’ll see
io.ktor.features.CannotTransformContentToTypeException: Cannot transform this request's content to com.example.recipecollection.backend.model.Recipe
This happens because we forgot to install ContentNegotiation
between the client and the server to help us serialize objects. Let’s do it now, add this to you application:
install(ContentNegotiation) {
json()
}
Build the backend and try sending the request again.
Voilà!
We get the recipe id
in return.
Now try posting the Recipe
again.
Then try the GET /recipes
endpoint and you’ll get our Pizza Dough recipe in return.
Time to start cooking! 🧑🍳
By now we’ve prepared everything to setup our local database. We’ll use postgreSQL for it. If you don’t have it installed already, follow the instructions here.
Then create a new database for our app with the following sequence of commands from the terminal:
psql -U postgres
CREATE DATABASE recipecollection;
(If you later need to delete the database, use DROP DATABASE recipecollection;
)
Pay attention to “;”
!
Then switch to the newly created database:
You should see the following message:
You are now connected to database recipecollection
as user postgres
.
Congrats, you are a proud owner of a local database! It’s empty for now, but you don’t need to create the tables manually, as our exposed backend will take care of it with the help of all the tables we’ve already defined in our app.
We’ll use JDBC (Java Database Connectivity) to manage connection to our database and execute requests. A lightweight JDBC library HikariCP
will help us create a connection pool that can be reused when we make a new request to the database. Let’s add missing dependencies to our app:
Add these to your Versions.kt:
const val POSTGRESQL_VERSION = "42.3.1"
const val HIKARI_VESRION = "5.0.0"
...
object Jvm {
const val HIKARI_CONNECTION_POOL = "com.zaxxer:HikariCP:$HIKARI_VESRION"
const val POSTGRESQL = "org.postgresql:postgresql:$POSTGRESQL_VERSION"
}
And then use the dependencies in the backend
module:
implementation(Versions.Jvm.POSTGRESQL)
implementation(Versions.Jvm.HIKARI_CONNECTION_POOL)
Sync your project dependencies.
Now let’s go back to the app and actually create a local source implementation so that we can connect it to our REST api.
First, create a file LocalSource.kt
in the exposed
directory and define an interface:
internal interface LocalSource {
}
Later on, we’ll think about what methods we want to use here to access the data in our database. For now we’re more interested in implementing and configuring it. So let’s create an implementation for our LocalSource
. Add a LocalSourceImpl.kt
next to LocalSource.kt
. Here we’ll use HikariCP
functionality to configure our database connection and afterwards fill up the database with the tables we specified before within the Exposed
framework:
@OptIn(ObsoleteCoroutinesApi::class)
internal class LocalSourceImpl(application: io.ktor.application.Application) : LocalSource {
private val dispatcher: CoroutineContext
init {
val config = application.environment.config.config("database") [1]
val url = System.getenv("JDBC_DATABASE_URL") [2]
val driver = config.property("driver").getString()
val poolSize = config.property("poolSize").getString().toInt()
application.log.info("Connecting to db at $url")
dispatcher = newFixedThreadPoolContext(poolSize, "database-pool") [3]
val hikariConfig = HikariConfig().apply {
jdbcUrl = url
maximumPoolSize = poolSize
driverClassName = driver
validate()
}
Database.connect(HikariDataSource(hikariConfig)) [4]
transaction { [5]
SchemaUtils.createMissingTablesAndColumns(
RecipeTable,
IngredientTable,
InstructionTable
)
}
}
}
[1] Here we specify some of the configuration parameters. Therefore we’ll also need to extend our application.conf
file with the database driver
and poolSize
. Add this to application.conf
:
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [dev.tutorial.kmpizza.backend.MainKt.module]
}
}
database {
driver = "org.postgresql.Driver"
poolSize = 20
}
[2] We will get the URL for our database from environment variables. To test the database locally we can set this variable in our terminal, which I’ll show you how to do later
[3] Create a coroutine execution context with the fixed-size thread-pool
[4] Connect to our exposed database using Hikari Configuration
[5] Create missing tables from our definitions
Now that we’ve set up a backend and a local database, let’s bind them together.
We’ll use Koin
dependency injection for it. First, add these to Versions.kt
:
const val KOIN_VERSION = "3.1.2"
object Jvm {
const val KOIN_KTOR = "io.insert-koin:koin-ktor:$KOIN_VERSION"
…
}
Add the dependency to your backend build.gradle.kts
:
implementation(Versions.Jvm.KOIN_KTOR)
Sync your dependencies.
Now we can create a Koin Module. Add KoinModule.kt
to a new directory “di” (as in “dependency injection”) in your backend module.

Create a module with a single local source, adding other necessary imports:
import io.ktor.application.*
import org.koin.dsl.module
internal fun Application.getKoinModule() = module {
single<LocalSource> { LocalSourceImpl(this@getKoinModule) }
}
Then add Koin to Application
module in your Main.kt
:
import org.koin.ktor.ext.Koin
internal fun Application.module() {
install(Koin) {
modules(getKoinModule())
}
install(Routing) {
api()
}
}
Ok, let’s test how our local source works with the backend. We’ll add one test function to the localSource
:
internal interface LocalSource {
suspend fun getPizza(): String
}
And implement this function in LocalSourceImpl
:
override suspend fun getPizza(): String = withContext(dispatcher) {
"Pizza!"
}
Now let’s change our routing. First, change the Routing.hello()
function:
private fun Routing.pizza(localSource: LocalSource) {
get("/pizza") {
localSource.getPizza().let {
call.respond(it)
}
}
}
And change Routing.api()
:
internal fun Routing.api(localSource: LocalSource) {
pizza(localSource)
}
To finally connect all together we need to inject local source in our backend application and use it with Routing. Change Application.module()
to look like this:
internal fun Application.module() {
install(Koin) {
modules(getKoinModule())
}
val localSource by inject<LocalSource>()
install(Routing) {
api(localSource)
}
}
Time to test!
First, rebuild and make the project.
Then in the terminal add the local environment variable to your environment:
export JDBC_DATABASE_URL=jdbc:postgresql:recipecollection?user=postgres
And then run our backend locally:
java -jar ./backend/build/libs/Backend.jar -port=9000
Go to localhost:9000/pizza
and if you get a pizza craving from this request, then we’re on the right track!

Next challenge: let’s prepare a database that will store all our recipes. We’ll use a Jetbrains Exposed ORM framework coupled with a postgres SQL for it.
First, add necessary dependencies for Exposed to your project.
Add the exposed maven repository to your project level build.gradle.kts
:
allprojects {
repositories {
google()
mavenCentral()
maven("https://dl.bintray.com/kotlin/exposed")
}
}
In you Versions.kt
declare relevant dependencies:
object Versions {
const val EXPOSED_VERSION = "0.36.2"
. . .
object Jvm {
const val JETBRAINS_EXPOSED_CORE = "org.jetbrains.exposed:exposed-core:$EXPOSED_VERSION"
const val JETBRAINS_EXPOSED_DAO = "org.jetbrains.exposed:exposed-dao:$EXPOSED_VERSION"
const val JETBRAINS_EXPOSED_JDBC = "org.jetbrains.exposed:exposed-jdbc:$EXPOSED_VERSION"
}
And then use them in your backend module:
implementation(Versions.Jvm.JETBRAINS_EXPOSED_CORE)
implementation(Versions.Jvm.JETBRAINS_EXPOSED_DAO)
implementation(Versions.Jvm.JETBRAINS_EXPOSED_JDBC)
Don’t forget to sync your project.
Now that all dependencies are implemented, let’s get down to designing our database.
Our SQL database will have three tables: Ingredients
, Instructions
and Recipes
. Every Entity in Exposed DAO has a table which extends IntIdTable
and defines the relationships between variables. Then we can use those relationships to create Entities
and transform them into classes which we can later use in our application.
First, let’s create tables for each class. Add a new package called storage
to your backend package. Then add a package called exposed
inside the storage package. Prepare your project structure for the changes to look like this:

Then add the following tables in relevant files and perform necessary import from org.jetbrains.exposed
along the way:
internal object IngredientTable : IntIdTable("ingredients") { [1]
val name = varchar("name", 100) [2]
val amount = decimal("amount", 2, 1) [3]
val metric = varchar("metric", 100)
val recipe = reference( [4]
"recipe_id",
RecipeTable,
onDelete = ReferenceOption.CASCADE,
onUpdate = ReferenceOption.CASCADE
) [5]
}
internal object InstructionTable : IntIdTable("instructions") {
val order = integer("order") [3]
val description = varchar("description", 100)
val recipe = reference(
"recipe_id",
RecipeTable,
onDelete = ReferenceOption.CASCADE,
onUpdate = ReferenceOption.CASCADE
)
}
internal object RecipeTable : IntIdTable("recipes") {
val title = varchar("title", 100)
}
[1] We create a table which gets Integer ids automatically and name it “ingredients”. This table holds such properties as name, amount, metric (like in our data class that we defined before). Similarly, the instruction table holds previously defined properties. Recipe table only has a title (read on to find out what to do with instructions and ingredients).
[2] When we need to create a text property, we use varchar.
[3] For decimal/double values we use decimal and integer for integer values.
[4] Now, our recipes actually should have a list of ingredients and instructions. In this simplified database every “ingredient” and “instruction” comes up only in one recipe, but a recipe has a number of ingredients and instructions. Therefore we define a reference in our “ingredients” and “instructions” tables that will point to the recipe where they are used.
[5] onDelete and onCascade properties tell us how these entries in the table will behave if our parent gets removed. In this case, if the recipe is deleted, the ingredients and instructructions that reference that recipe also get deleted.
Now we can build our entities based on these relations. Add the following code to IngredientEntity.kt
:
class IngredientEntity(id: EntityID<Int>) : IntEntity(id) { [1]
companion object : IntEntityClass<IngredientEntity>(IngredientTable)
var name by IngredientTable.name
var amount by IngredientTable.amount
var metric by IngredientTable.metric
var recipe by RecipeEntity referencedOn IngredientTable.recipe [2]
}
fun IngredientEntity.toIngredient() = Ingredient( [3]
id.value.toLong(),
name,
amount.toDouble(),[4]
metric
)
[1] Create an Entity based on the relations in IngredientTable. The Long id property is generated automatically.
[2] Here we specify the Recipe Entity where the instruction comes up, using a reference to the Recipe table that we’ve created
[3] Now we can map our database Entity to our class that we defined
earlier so that we can later use it in the application
[4] The amount is stored as BigDecimal in the database, but we want to use the Double, so we need to convert it
As you can see, now our data classes don’t really match with the entities because of ids. Let’s complete our data classes with ids as follows:
data class Recipe(
val id: Long = 0,
val title: String,
val ingredients: List<Ingredient>,
val instructions: List<Instruction>
)
Add an id to Ingredient
and Instruction
as well.
data class Ingredient(
val id: Long = 0,
val name: String,
val amount: Double,
val metric: String
)
data class Instruction(
val id: Long = 0,
val order: Int,
val description: String,
)
Do the same procedure for Instructions. Don’t worry if some things are not resolved at first, we’ll get back to them later.
class InstructionEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<InstructionEntity>(InstructionTable)
var order by InstructionTable.order
var description by InstructionTable.description
var recipe by RecipeEntity referencedOn InstructionTable.recipe
}
fun InstructionEntity.toInstruction() = Instruction(
id.value.toLong(),
order,
description
)
We only need to create RecipeEntity itself now. Here we’ll need to use those relationships that we’ve defined in Ingredients and Instructions.
class RecipeEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<RecipeEntity>(RecipeTable)
var title by RecipeTable.title
val ingredients by IngredientEntity referrersOn IngredientTable.recipe [1]
val instructions by InstructionEntity referrersOn InstructionTable.recipe
}
fun RecipeEntity.toRecipe() = Recipe(
id.value.toLong(),
title,
ingredients.map{it.toIngredient()}, [2]
instructions.map{it.toInstruction()}
)
[1] Here we collect all the ingredients and instructions that reference this recipe and create a SizedIterable<IngredientEntity>
or SizedIterable<InstructionEntity>
[2] When transforming our entity to a data class, we need to map our SizedInterable
to a List
Fix missing imports in all tables and entities, if there are any left - this will make all previous unresolved references go away.
So far so good! We’ve made the necessary preparations to set up our local database.
In the next step we’ll proceed with postgresSQL.
By now you must be very excited to try it out. At least I am. Teaching yourself software development always leaves room for surprise. What if it doesn’t work? 😰
To build and test our backend we’ll use a jar file, which we will create with the help of shadowJar library.
First, let’s add the Shadow Jar version to the Versions.kt
:
const val SHADOW_JAR_VERSION = "7.1.1"
And then to configure it in backend build.gradle.kts
add the following to the plugins
section:
id("com.github.johnrengelman.shadow") version Versions.SHADOW_JAR_VERSION
This will automatically add a shadowJar task to our project and configure it to include the sources from the main sourceSet. We will also configure the task to set the name and output extension. To do so, first add an import to the top of your build.gradle.kts
:
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
And then add the configuration after your dependencies in build.gradle.kts
:
tasks.withType<ShadowJar> {
archiveBaseName.set("Backend")
archiveClassifier.set("")
archiveVersion.set("")
isZip64 = true
}
Before we can test our backend let’s implement simple routing.
In the Application function install the routing:
import io.ktor.application.*
import io.ktor.routing.*
internal fun Application.module() {
install(Routing) {
api()
}
}
Immediately create a separate Api.kt
file, where we’ll list all the routes like this:
import io.ktor.application.*
import io.ktor.response.*
import io.ktor.routing.*
internal fun Routing.api() {
hello()
}
private fun Routing.hello() {
get("/hello") {
call.respondText("Hello!")
}
}
Now
(1) rebuild
(2) make the project

If you get a junit error, then just delete the test folder from the backend module - we will not need it. And try again.
Then check the build folder: here it is, our Backend.jar 🤩

Let’s try to start the backend from the command line.
First, open the terminal window and go to your KMP project.
As mentioned above, we can always pass required parameters to our server application through environment variables.
For example, we can override the port that we specified in application.conf
:
java -jar ./backend/build/libs/backend.jar -port=9000
Then open your web browser at localhost:9000/hello
.
If it greets you, then it’s a good sign!

Congrats, we’ve created a simple backend REST API with our first get function!
You can reward yourself with a slice of pizza now.
Now that we’ve made sure the app is alive, let’s start building up our backend.
We’ll have a recipe collection, so we’ll need a recipe, which consists of a list of ingredients and a list of instructions.
A simplified model of our recipes will look like this:
data class Recipe(
val title: String,
val ingredients: List<Ingredient>,
val instructions: List<Instruction>
)
data class Ingredient(
val name: String,
val amount: Double,
val metric: String
)
data class Instruction(
val order: Int,
val description: String
)
Let’s create a model directory in our backend module and add our data classes in a file called Entity.kt
.

That’s good enough of a model. Realistic, but not too complicated.
What’s next?

Next we will set up a simple, but powerful server using the Ktor framework. Ktor is easy to use and has great multiplatform capabilities, which will benefit us a lot in the future when we get down to writing our networking client for the apps. First, let’s define Ktor dependencies in Versions.kt
.
object Versions {
const val KTOR_VERSION = "1.6.7"
object Jvm {
const val KTOR_AUTH = "io.ktor:ktor-auth:$KTOR_VERSION"
const val KTOR_WEB_SOCKETS = "io.ktor:ktor-websockets:$KTOR_VERSION"
const val KTOR_CLIENT_APACHE = "io.ktor:ktor-client-apache:$KTOR_VERSION"
const val KTOR_SERVER_NETTY = "io.ktor:ktor-server-netty:$KTOR_VERSION"
const val KTOR_SERIALIZATION = "io.ktor:ktor-serialization:$KTOR_VERSION"
}
}
After syncing your gradle files, go to the backend module and if you have build.gradle
instead of build.gradle.kts
, change the extension, as we’re going to use Kotlin DSL throughout the project. Then add Ktor dependencies. Your build.gradle.kts
should look like this:
plugins {
kotlin("jvm")
}
dependencies {
// Ktor
implementation(Versions.Jvm.KTOR_CLIENT_APACHE)
implementation(Versions.Jvm.KTOR_SERIALIZATION)
implementation(Versions.Jvm.KTOR_SERVER_NETTY)
implementation(Versions.Jvm.KTOR_AUTH)
implementation(Versions.Jvm.KTOR_WEB_SOCKETS)
implementation(Versions.Jvm.KTOR_CLIENT_APACHE)
}
Now we are ready to create our server. We’ll use embeddedServer and load configuration settings from the application.conf
HOCON file, which we’ll place in the application resources. With Ktor you can configure various server parameters. When you create your server with embeddedServer you’ll need to specify the parameters in the constructor. Add Server.kt
file to your backend and write the following:
import io.ktor.server.engine.commandLineEnvironment
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
fun main(args: Array<String>) {
embeddedServer(Netty, commandLineEnvironment(args)).apply {
start()
}
}
Now let’s add an application.conf
file to resources directory in backend/main (you won’t need other resources in your backend module, so you can easily delete all other directories that were created automatically, like the drawables and mipmaps, as well as test, androidTest, AndroidManifest and proguard-rules):

Add the following to your application.conf
:
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [dev.tutorial.kmpizza.backend.MainKt.module]
}
}
Here we configure our port: we’ll use 8080 by default for local development and in the future will be able to set the PORT variable from our environment with port = ${?PORT}.
Pay attention to the module path matching your package name!
Now add Main.kt
to your backend, next to Server.kt
.
This file will contain one function, corresponding to the module that we’ve just specified in our application.conf
.
import io.ktor.application.*
internal fun Application.module() {
}
You will also need to specify your Server in the build.gradle.kts
. Now your backend build.gradle.kts
file should look as follows:
application {
mainClass.set("dev.tutorial.backend.ServerKt")
}
plugins {
kotlin("jvm")
application
}
dependencies {
// Ktor
implementation(Versions.Jvm.KTOR_CLIENT_APACHE)
implementation(Versions.Jvm.KTOR_SERIALIZATION)
implementation(Versions.Jvm.KTOR_SERVER_NETTY)
implementation(Versions.Jvm.KTOR_AUTH)
implementation(Versions.Jvm.KTOR_WEB_SOCKETS)
implementation(Versions.Jvm.KTOR_CLIENT_APACHE)
}
As always, sync the dependencies.
Now we’re done with the boring part: this basic Ktor setup will be enough to begin with.
Soon we’ll be able to test our backend.
What do we need to serve our pizza?
Right! A backend.
And you know what, we can do that with Kotlin. So let’s start.
Add another module to our basic setup: backend. Click on the project and choose New -> Module. Name it “backend”. Choose “No Activity” when asked to create one.

If you check out the project settings.gradle.kts, you will see that the backend module is included in the project now.
rootProject.name = "kmpizza"
include(":androidApp")
include(":shared")
include(":backend")
As our recipe collection is going to grow into a big project for multiple platforms, we’ll need to keep our dependencies clean and organised. To do so, we’ll setup buildSrc for dependency management. Add a new directory to the root folder and name it “buildSrc”.

In this module create Versions.kt and build.gradle.kts to match the following file structure:

In Versions.kt file you wil keep all the dependencies for different modules in this project.
Add the following to the build.gradle.kts file:
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
maven("https://dl.bintray.com/kotlin/kotlinx/")
}
To make sure we haven’t broken anything yet, sync the project, choose androidApp configuration and run the app.

You’ll see a “Hello, Android!” on your device, which is an initial setup that comes with the KMM Application Android Studio Project.
If you want to try it in XCode, open iosApp.xcworkspace.

Then run it on a simulator and you’ll see a “Hello, iOS!” screen. Great, everything is still working 😉
Or is it? If you try running it on a real device you may get an error Signing for "iosApp" requires a development team. Select a development team in the Signing & Capabilities editor.
Let’s quickly fix it.
Select the project name in XCode and go to Signing&Capabilites in the project editor. There change your team to Personal Team, something like this:

Now you may see another error if you try running the app again:
Command PhaseScriptExecution failed with a nonzero exit code
😱
No worries, we’ll fix it too. First, you need to sync the gradle project in Android Studio, then go to the iosApp directory in your terminal and run pod install
there.
Now try running again.
If you get a warning like this

Then go to Settings -> General -> Device Management and choose to trust your developer account.
Run the app again. Finally we see the greeting on a real device!
And just like this, one step at a time we’ll continue building our multiplatform app. 🍕
Everyone likes pizza, so let’s create a recipe app, where you can save all the yummy pizza recipes that you’ve tried and would like to recreate sometime in the future!
Our first step will be to create a Kotlin Multiplatform project.
-
Open Android studio, go to File -> New -> New Project and create a KMM Application.

-
Choose a name for the application and the package, define your save location and click Next.

-
In the following screen you’ll have to name your Android Application, iOS Application and shared Module. You don’t need to customize anything here, so just leave it as is and click Finish.

Now we’ve got a basic setup for our future KMM project. This way we can keep all our project related Kotlin code in the same place. As you can see, it has an androidApp, where you can work on your Android App just as you usually would. You also have an iosApp that holds your ios application, which you can work with in XCode. Moreover, you have a shared module now, which is divided into androidMain, commonMain and iosMain. CommonMain will be the source of all your shared logic within the app, and the other two will hold platform specific implementations for expect functions and classes.