Ktor 3.1.3 Help

Build a full-stack application with Kotlin Multiplatform

In this article, you will learn how to develop a full-stack application in Kotlin that runs on Android, iOS, and desktop platforms, all while leveraging Ktor for seamless data handling.

By the end of this tutorial, you’ll know how to do the following:

  • Create full-stack applications using Kotlin Multiplatform.

  • Understand the project generated with IntelliJ IDEA.

  • Create Compose Multiplatform clients that call Ktor services.

  • Reuse shared types across different layers of your design.

  • Correctly include and configure multiplatform libraries.

In previous tutorials, we used the Task Manager example to handle requests, create RESTful APIs, and integrate a database with Exposed. Client applications were kept as minimal as possible, so you could focus on learning the fundamentals of Ktor.

You will create a client that will target the Android, iOS, and desktop platforms, using a Ktor service to acquire the data to be displayed. Wherever possible you will share data types between the client and the server, speeding up development and reducing the potential for errors.

Prerequisites

As in previous articles, you will use IntelliJ IDEA as the IDE. To learn how to install and configure your environment, see the Kotlin Multiplatform quickstart guide.

If this is the first time you use Compose Multiplatform, we recommend that you complete the Get started with Compose Multiplatform tutorial before starting this one. To reduce the complexity of the task, you can focus on a single client platform. For example, if you have never used iOS, it might be wise to focus on desktop or Android development.

Create a new project

Instead of choosing the Ktor Project Generator to create the initial project, you will use the IntelliJ IDEA project wizard. It will create a project to which you can add clients and services. The clients can either use a native UI library, such as SwiftUI, or share the same UI by using Compose Multiplatform.

  1. Launch IntelliJ IDEA.

  2. In IntelliJ IDEA, select File | New | Project.

  3. In the panel on the left, select Kotlin Multiplatform.

  4. Specify the following fields in the New Project window:

    • Name: full-stack-task-manager

    • Group: com.example.ktor

  5. Select Android, Desktop, and Server as the targeted platforms.

  6. If you're using a Mac, select iOS as well. Make sure that the Share UI option is selected.

    Kotlin Multiplatform wizard settings

  7. Click the Create button and wait for the IDE to generate and import the project.

Run the service

  1. In the Files panel, navigate to server/src/main/kotlin/com/example/ktor/full_stack_task_manager and open the Application.kt file.

  2. Click on the Run button (IntelliJ IDEA run icon) next to the main() function to start the application.

    The service will start in a new Terminal tool window at the bottom of the IDE.

  3. Navigate to http://0.0.0.0:8080/ to open the application. You should see a message from Ktor displayed in the browser.

    A Ktor server browser response

Examine the project

The server folder is one of three Kotlin modules in the project. The other two are shared and composeApp.

The structure of the server module is very similar to that produced by the Ktor Project Generator. You have a dedicated build file to declare plugins and dependencies, and a source set containing the code to build and launch a Ktor service:

Contents of the server folder in a Kotlin Multiplatform project

If you look at the routing instructions in the Application.kt file, you will see an extra feature, which is not present in the ‘Hello World’ code created by the Ktor Project Generator:

fun Application.module() { routing { get("/") { call.respondText("Ktor: ${Greeting().greet()}") } } }

This creates an instance of the Greeting type and invokes its greet() method. If you look for this type in the server module you will not be able to find it. Instead, you will find it within the shared module:

Greeting.kt and Platform.kt opened in IntelliJ IDEA

The shared module contains code that will be used across different target platforms.

The commonMain source set holds types that will be used on all platforms. As you can see this is where the Greeting type lives. This is where you will put the common code that you want to share between client and server, and also between different client platforms.

The shared module also contains a source set for each platform where you wish to provide a client. This is because types declared within commonMain may require functionality that varies by target platform. In the case of the Greeting type, you want to get the name of the current platform. This is achieved through expected and actual declarations.

In the commonMain source set of the shared module you declare a getPlatform() function with the expect keyword.

package com.example.ktor.full_stack_task_manager interface Platform { val name: String } expect fun getPlatform(): Platform

Then each target platform must provide an actual declaration of the getPlatform() function, as shown below:

package com.example.ktor.full_stack_task_manager import platform.UIKit.UIDevice class IOSPlatform: Platform { override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion } actual fun getPlatform(): Platform = IOSPlatform()
package com.example.ktor.full_stack_task_manager import android.os.Build class AndroidPlatform : Platform { override val name: String = "Android ${Build.VERSION.SDK_INT}" } actual fun getPlatform(): Platform = AndroidPlatform()
package com.example.ktor.full_stack_task_manager class JVMPlatform: Platform { override val name: String = "Java ${System.getProperty("java.version")}" } actual fun getPlatform(): Platform = JVMPlatform()

There is one additional module in the project, the composeApp module. This is where the Android, iOS, and Desktop clients live. You can run these as specified in the ‘Run your application’ step of the Compose Multiplatform tutorial.

These clients are not linked to the Ktor service at the moment, but they do use the shared Greeting class.

Run the client application

You can run the client application by executing the run configuration for the target. To run the application on an iOS simulator, follow the steps below:

  1. In IntelliJ IDEA, select the iosApp run configuration and a simulated device.

    Run & Debug window
  2. Click on the Run button (IntelliJ IDEA run icon) to run the configuration.

  3. When you run the iOS app, it is built with Xcode under the hood and launched in the iOS Simulator. The app displays a button that toggles an image on click.

    Running the app in the iOS Simulator

    When the button is pressed for the first time, the details of the current platform are added to its text. The code to achieve this is found in composeApp/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager/App.kt:

    @Composable fun App() { MaterialTheme { var greetingText by remember { mutableStateOf("Hello World!") } var showImage by remember { mutableStateOf(false) } Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Button(onClick = { greetingText = "Compose: ${Greeting().greet()}" showImage = !showImage }) { Text(greetingText) } AnimatedVisibility(showImage) { Image( painterResource(Res.drawable.compose_multiplatform), null ) } } } }

    This is a composable function, which you will modify later in this article. For the moment all that is important is that it displays a UI and makes use of the Greeting type, which in turn uses the platform-specific Platform type.

Now that you understand the structure of the generated project, you can incrementally include the task manager functionality from previous articles.

Add the model types

First, you will add the model types and make sure that they are accessible on both the client and the server.

  1. Navigate to shared/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager and create a new package called model.

  2. Inside the new package, create a new file called Task.kt.

  3. Add an enum to represent priorities and a class to represent tasks. The Task class is annotated with the Serializable type from the kotlinx.serialization library:

    import kotlinx.serialization.Serializable enum class Priority { Low, Medium, High, Vital } @Serializable data class Task( val name: String, val description: String, val priority: Priority )

    You will notice that neither the import nor the annotation compiles. This is because the kotlinx.serialization library has not been included for us by the wizard.

  4. Navigate to shared/build.gradle.kts and add the serialization plugin:

    plugins { //... kotlin("plugin.serialization") version "2.1.21" }
  5. In the same file, add a new dependency to the commonMain source set:

    sourceSets { commonMain.dependencies { // put your Multiplatform dependencies here implementation(libs.kotlinx.serialization.json) } //... }
  6. Navigate to gradle/libs.versions.toml and define the following:

    [versions] kotlinxSerializationJson = "1.8.1" [libraries] kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
  7. In IntelliJ IDEA, click the Gradle icon on the top right of the panel to sync the build file. Once loading has finished, you should find that your Task.kt file compiles successfully.

Note that the code would have compiled without including the serialization plugin, however, the types required to serialize Task objects across the network would not have been produced. This would lead to runtime errors when attempting to invoke the service.

Placing the serialization plugin in another module (such as server or composeApp) would not have caused an error at build time. But again, the additional types required for serialization would not have been generated, leading to runtime errors.

Create the server

The next stage is to create the server implementation for our task manager.

  1. Navigate to the server/src/main/kotlin/com/example/ktor/full_stack_task_manager folder and create a subpackage called model.

  2. Inside this package, create a new TaskRepository.kt file and add the following interface for our repository:

    interface TaskRepository { fun allTasks(): List<Task> fun tasksByPriority(priority: Priority): List<Task> fun taskByName(name: String): Task? fun addOrUpdateTask(task: Task) fun removeTask(name: String): Boolean }
  3. In the same package, create a new file called InMemoryTaskRepository.kt containing the following class:

    class InMemoryTaskRepository : TaskRepository { private var tasks = listOf( Task("Cleaning", "Clean the house", Priority.Low), Task("Gardening", "Mow the lawn", Priority.Medium), Task("Shopping", "Buy the groceries", Priority.High), Task("Painting", "Paint the fence", Priority.Low), Task("Cooking", "Cook the dinner", Priority.Medium), Task("Relaxing", "Take a walk", Priority.High), Task("Exercising", "Go to the gym", Priority.Low), Task("Learning", "Read a book", Priority.Medium), Task("Snoozing", "Go for a nap", Priority.High), Task("Socializing", "Go to a party", Priority.High) ) override fun allTasks(): List<Task> = tasks override fun tasksByPriority(priority: Priority) = tasks.filter { it.priority == priority } override fun taskByName(name: String) = tasks.find { it.name.equals(name, ignoreCase = true) } override fun addOrUpdateTask(task: Task) { var notFound = true tasks = tasks.map { if (it.name == task.name) { notFound = false task } else { it } } if (notFound) { tasks = tasks.plus(task) } } override fun removeTask(name: String): Boolean { val oldTasks = tasks tasks = tasks.filterNot { it.name == name } return oldTasks.size > tasks.size } }
  4. Navigate to Application.kt and replace the existing code with the implementation below:

    package com.example.ktor.full_stack_task_manager import InMemoryTaskRepository import Task import io.ktor.http.HttpStatusCode import io.ktor.serialization.JsonConvertException import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.request.receive import io.ktor.server.response.* import io.ktor.server.routing.* fun main() { embeddedServer(Netty, port = SERVER_PORT, host = "0.0.0.0", module = Application::module) .start(wait = true) } fun Application.module() { install(ContentNegotiation) { json() } val repository = InMemoryTaskRepository() routing { route("/tasks") { get { val tasks = repository.allTasks() call.respond(tasks) } get("/byName/{taskName}") { val name = call.parameters["taskName"] if (name == null) { call.respond(HttpStatusCode.BadRequest) return@get } val task = repository.taskByName(name) if (task == null) { call.respond(HttpStatusCode.NotFound) return@get } call.respond(task) } get("/byPriority/{priority}") { val priorityAsText = call.parameters["priority"] if (priorityAsText == null) { call.respond(HttpStatusCode.BadRequest) return@get } try { val priority = Priority.valueOf(priorityAsText) val tasks = repository.tasksByPriority(priority) if (tasks.isEmpty()) { call.respond(HttpStatusCode.NotFound) return@get } call.respond(tasks) } catch (ex: IllegalArgumentException) { call.respond(HttpStatusCode.BadRequest) } } post { try { val task = call.receive<Task>() repository.addOrUpdateTask(task) call.respond(HttpStatusCode.NoContent) } catch (ex: IllegalStateException) { call.respond(HttpStatusCode.BadRequest) } catch (ex: JsonConvertException) { call.respond(HttpStatusCode.BadRequest) } } delete("/{taskName}") { val name = call.parameters["taskName"] if (name == null) { call.respond(HttpStatusCode.BadRequest) return@delete } if (repository.removeTask(name)) { call.respond(HttpStatusCode.NoContent) } else { call.respond(HttpStatusCode.NotFound) } } } } }

    This implementation is very similar to that in previous tutorials with the exception that now you have placed all the routing code within the Application.module() function for simplicity.

    Once you have entered this code and added the imports, you will find that there is an outstanding compiler error. This is because the ContentNegotiation plugin was not included by the project wizard.

  5. Open the gradle/libs.versions.toml file and define the following libraries:

    [libraries] ktor-serialization-kotlinx-json-jvm = { module = "io.ktor:ktor-serialization-kotlinx-json-jvm", version.ref = "ktor" } ktor-server-content-negotiation-jvm = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" }
  6. Open the server-side build file (server/build.gradle.kts) and add the following dependencies:

    dependencies { //... implementation(libs.ktor.serialization.kotlinx.json.jvm) implementation(libs.ktor.server.content.negotiation.jvm) }
  7. Re-sync the build file. You should find that you can now import the ContentNegotiation type and json() function.

  8. Rerun the server. You should find that the routes are reachable from the browser.

  9. Navigate to http://0.0.0.0:8080/tasks and http://0.0.0.0:8080/tasks/byPriority/Medium to see the server response of tasks in a JSON format.

    Server response in browser

Create the client

For your clients to be able to access the server, you need to include the Ktor Client. There are three types of dependency involved in this:

  • The core functionality for the Ktor Client.

  • Platform-specific engines to handle networking.

  • Support for content negotiation and serialization.

  1. In the gradle/libs.versions.toml file, add the following libraries:

    [libraries] ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
  2. Navigate to composeApp/build.gradle.kts and add the following dependencies:

    sourceSets { val desktopMain by getting androidMain.dependencies { //... implementation(libs.ktor.client.android) } commonMain.dependencies { //... implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) } desktopMain.dependencies { //... implementation(libs.ktor.client.cio) } iosMain.dependencies { implementation(libs.ktor.client.darwin) } }

    Once this is done you can add a TaskApi type for your clients to act as a thin wrapper around the Ktor Client.

  3. Click the Gradle icon in the top right corner to load the changes in the build file.

  4. Navigate to composeApp/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager and create a new package called network.

  5. Inside the new package, create a new HttpClientManager.kt for the client configuration:

    package com.example.ktor.full_stack_task_manager.network import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json fun createHttpClient() = HttpClient { install(ContentNegotiation) { json(Json { encodeDefaults = true isLenient = true coerceInputValues = true ignoreUnknownKeys = true }) } defaultRequest { host = "1.2.3.4" port = 8080 } }

    Note you should replace 1.2.3.4 with the IP address of your current machine. You will not be able to make calls to 0.0.0.0 or localhost from code running on an Android Virtual Device or the iPhone simulator.

  6. In the same network package, create a new TaskApi.kt file with the following implementation:

    import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType class TaskApi(private val httpClient: HttpClient) { suspend fun getAllTasks(): List<Task> { return httpClient.get("tasks").body() } suspend fun removeTask(task: Task) { httpClient.delete("tasks/${task.name}") } suspend fun updateTask(task: Task) { httpClient.post("tasks") { contentType(ContentType.Application.Json) setBody(task) } } }
  7. Navigate to App.kt and replace the App composable with the implementation below:

    package com.example.ktor.full_stack_task_manager import Task import TaskApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.example.ktor.full_stack_task_manager.network.createHttpClient import kotlinx.coroutines.launch @Composable fun App() { MaterialTheme { val httpClient = createHttpClient() val taskApi = remember { TaskApi(httpClient) } val tasks = remember { mutableStateOf(emptyList<Task>()) } val scope = rememberCoroutineScope() Column( modifier = Modifier .safeContentPadding() .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, ) { Button(onClick = { scope.launch { tasks.value = taskApi.getAllTasks() } }) { Text("Fetch Tasks") } for (task in tasks.value) { Text(task.name) } } } }

    This will use the TaskApi type to retrieve the list of tasks from the server, and then display the name of each one in a column.

  8. Test the application by running the iOS client. Click on the Fetch Tasks button to display the list of tasks:

    App running on iOS

  9. On the Android platform, you need to explicitly give the application networking permissions and allow it to send and receive data in cleartext. To enable these permissions open composeApp/src/androidMain/AndroidManifest.xml and add the following settings:

    <manifest> ... <application android:usesCleartextTraffic="true"> ... ... </application> <uses-permission android:name="android.permission.INTERNET"/> </manifest>
  10. Run the application. You should now find that your Android client will run as well:

    App running on Android

  11. In the case of the desktop client, you need to assign dimensions and a title to the containing window. Open the file composeApp/src/desktopMain/kotlin/com/example/ktor/full_stack_task_manager/main.kt and modify the code by changing the title and setting the state property:

    package com.example.ktor.full_stack_task_manager import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.application fun main() = application { val state = WindowState( size = DpSize(400.dp, 600.dp), position = WindowPosition(200.dp, 100.dp) ) Window( title = "Task Manager (Desktop)", state = state, onCloseRequest = ::exitApplication ) { App() } }
  12. Run the application. The desktop client should now reflect the changes:

    App running on desktop

Improve the UI

The clients are now communicating with the server, but this is hardly a compelling user interface.

  1. Open the App.kt file located in composeApp/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager and replace the existing App with the App and TaskCard composables below:

    package com.example.ktor.full_stack_task_manager import Priority import Task import TaskApi import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.ktor.full_stack_task_manager.network.createHttpClient import kotlinx.coroutines.launch @Composable fun App() { MaterialTheme { val httpClient = createHttpClient() val taskApi = remember { TaskApi(httpClient) } var tasks by remember { mutableStateOf(emptyList<Task>()) } val scope = rememberCoroutineScope() LaunchedEffect(Unit) { tasks = taskApi.getAllTasks() } LazyColumn(modifier = Modifier.fillMaxSize().padding(8.dp)) { items(tasks) { task -> TaskCard( task, onDelete = { scope.launch { taskApi.removeTask(it) tasks = taskApi.getAllTasks() } }, onUpdate = { } ) } } } } @Composable fun TaskCard( task: Task, onDelete: (Task) -> Unit, onUpdate: (Task) -> Unit ) { fun pickWeight(priority: Priority) = when (priority) { Priority.Low -> FontWeight.SemiBold Priority.Medium -> FontWeight.Bold Priority.High, Priority.Vital -> FontWeight.ExtraBold } Card( modifier = Modifier.fillMaxWidth().padding(4.dp), shape = RoundedCornerShape(CornerSize(4.dp)) ) { Column(modifier = Modifier.padding(10.dp)) { Text( "${task.name}: ${task.description}", fontSize = 20.sp, fontWeight = pickWeight(task.priority) ) Row { OutlinedButton(onClick = { onDelete(task) }) { Text("Delete") } Spacer(Modifier.width(8.dp)) OutlinedButton(onClick = { onUpdate(task) }) { Text("Update") } } } } }

    With this implementation, your client now has some basic functionality.

    By using the LaunchedEffect type all tasks are loaded on startup, while the LazyColumn composable allows the user to scroll through tasks.

    Finally, a separate TaskCard composable is created, which in turn uses a Card to display the details of each Task. Buttons have been added to delete and update the task.

  2. Rerun the application. You can now scroll through tasks, view their details, and delete them:

    App running on Android with improved UI

Add update functionality

To complete the client, you need to incorporate the functionality to update tasks.

  1. Navigate to the App.kt file in composeApp/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager.

  2. Include the UpdateTaskDialog composable below:

    @Composable fun UpdateTaskDialog( task: Task, onConfirm: (Task) -> Unit ) { var description by remember { mutableStateOf(task.description) } var priorityText by remember { mutableStateOf(task.priority.toString()) } val colors = TextFieldDefaults.colors( focusedTextColor = Color.Blue, focusedContainerColor = Color.White, ) Dialog(onDismissRequest = {}) { Card( modifier = Modifier.fillMaxWidth().padding(4.dp), shape = RoundedCornerShape(CornerSize(4.dp)) ) { Column(modifier = Modifier.padding(10.dp)) { Text("Update ${task.name}", fontSize = 20.sp) TextField( value = description, onValueChange = { description = it }, label = { Text("Description") }, colors = colors ) TextField( value = priorityText, onValueChange = { priorityText = it }, label = { Text("Priority") }, colors = colors ) OutlinedButton(onClick = { val newTask = Task( task.name, description, try { Priority.valueOf(priorityText) } catch (e: IllegalArgumentException) { Priority.Low } ) onConfirm(newTask) }) { Text("Update") } } } } }

    This is a composable that displays the details of a Task with a dialog box. The description and priority are placed within TextField composables so they can be updated. When the user presses the update button, it triggers the onConfirm() callback.

  3. Navigate back to the App composable and update the code as shown below:

    @Composable fun App() { MaterialTheme { val httpClient = createHttpClient() val taskApi = remember { TaskApi(httpClient) } var tasks by remember { mutableStateOf(emptyList<Task>()) } val scope = rememberCoroutineScope() var currentTask by remember { mutableStateOf<Task?>(null) } LaunchedEffect(Unit) { tasks = taskApi.getAllTasks() } if (currentTask != null) { UpdateTaskDialog( currentTask!!, onConfirm = { scope.launch { taskApi.updateTask(it) tasks = taskApi.getAllTasks() } currentTask = null } ) } LazyColumn(modifier = Modifier.fillMaxSize().padding(8.dp)) { items(tasks) { task -> TaskCard( task, onDelete = { scope.launch { taskApi.removeTask(it) tasks = taskApi.getAllTasks() } }, onUpdate = { currentTask = task } ) } } } }

    You are storing an additional piece of state, which is the current task selected. If this value is not null, then we invoke our UpdateTaskDialog composable, with the onConfirm() callback set to send a POST request to the server using the TaskApi.

    Finally, when you are creating the TaskCard composables, you use the onUpdate() callback to set the currentTask state variable.

  4. Re-run the client application. You should now be able to update the details of each task by using the buttons.

    Deleting tasks on Android

Next steps

In this article, you have used Ktor within the context of a Kotlin Multiplatform application. You can now create a project containing multiple services and clients, targeting a range of different platforms.

As you have seen, it is possible to build out features without any code duplication or redundancy. The types required in all layers of the project can be placed within the shared multiplatform module. Functionality which is only required by the services goes in the server module, whilst functionality only required by the clients is placed in composeApp.

This kind of development inevitably requires knowledge of both client and server technologies. But you can use Kotlin Multiplatform libraries and Compose Multiplatform to minimize the amount of new material you need to learn. Even if your focus is initially only on a single platform, you can easily add others as demand for your application grows.

Last modified: 20 May 2025