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 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 the Ktor project generator, use the Kotlin Multiplatform project wizard in IntelliJ IDEA. It will create a basic multiplatform project which you can expand with clients and services. The clients can either use a native UI library, such as SwiftUI, but in this tutorial you will create a shared UI for all platforms by using Compose Multiplatform.
Launch IntelliJ IDEA.
In IntelliJ IDEA, select
.In the panel on the left, select
.Specify the following fields in the
window:: full-stack-task-manager
: com.example.ktor
Select
, , and as the targeted platforms.If you're using a Mac, select
as well. Make sure that the option is selected.Click the Create button and wait for the IDE to generate and import the project.
Run the service
In the server/src/main/kotlin/com/example/ktor/full_stack_task_manager and open the Application.kt file.
view, navigate toClick on the
button () next to the
main()
function to start the application.A new tab in the
tool window will open with the log ending with the message "Responding at http://0.0.0.0:8080".Navigate to http://0.0.0.0:8080/ to open the application. You should see a message from Ktor displayed in the browser.
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:

If you look at the routing instructions in the Application.kt file, you will see a call of the greet()
function:
This creates an instance of the Greeting
type and invokes its greet()
method. The Greeting
class is defined in the shared module:
The shared module contains code that will be used across different target platforms.
The commonMain source in the shared module set holds types that will be used on all platforms. As you can see this is where the Greeting
type is defined. This is also where you will put the common code to be shared between the server and all of the 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 using platform-specific API. 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:
Then each target platform must provide an actual
declaration of the getPlatform()
function, as shown below:
There is one additional module in the project, the composeApp module. It contains the code for the Android, iOS, desktop, and web client apps. These apps are not linked to the Ktor service at the moment, but they do use the shared Greeting
class.
Run a client application
You can run a client application by executing the run configuration for the target. To run the application on an iOS simulator, follow the steps below:
In IntelliJ IDEA, select the iosApp run configuration and a simulated device.
Click the
button () to run the configuration.
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.
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 shared
Greeting
type, which in turn uses the platform-specific classes implementing the commonPlatform
interface.
Now that you understand the structure of the generated project, you can incrementally add the task manager functionality.
Add the model types
First, add the model types and make sure that they are accessible for both the client and the server.
Navigate to shared/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager and create a new package called model.
Inside the new package, create a new file called Task.kt.
Add an
enum
to represent priorities and aclass
to represent tasks. TheTask
class is annotated with theSerializable
type from thekotlinx.serialization
library:package com.example.ktor.full_stack_task_manager.model 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 project doesn't have a dependency on the
kotlinx.serialization
library yet.Navigate to shared/build.gradle.kts and add the serialization plugin:
plugins { //... kotlin("plugin.serialization") version "2.1.21" }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) } //... }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" }In IntelliJ IDEA, select Task.kt file compiles successfully.
to apply the update. Once Gradle import has finished, you should find that your
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.
Navigate to the server/src/main/kotlin/com/example/ktor/full_stack_task_manager folder and create a subpackage called model.
Inside this package, create a new TaskRepository.kt file and add the following interface for our repository:
package com.example.ktor.full_stack_task_manager.model 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 }In the same package, create a new file called InMemoryTaskRepository.kt containing the following class:
package com.example.ktor.full_stack_task_manager.model 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 } }Navigate to server/src/main/kotlin/.../Application.kt and replace the existing code with the implementation below:
package com.example.ktor.full_stack_task_manager import com.example.ktor.full_stack_task_manager.model.InMemoryTaskRepository import com.example.ktor.full_stack_task_manager.model.Priority import com.example.ktor.full_stack_task_manager.model.Task import io.ktor.http.* import io.ktor.serialization.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* import io.ktor.server.request.* 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() } install(CORS) { allowHeader(HttpHeaders.ContentType) allowMethod(HttpMethod.Delete) // For ease of demonstration we allow any connections. // Don't do this in production. anyHost() } 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 multiple compiler errors because the code uses multiple Ktor plugins that need to be included as dependencies, including the CORS plugin for interacting with the web client.
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" } ktor-server-cors-jvm = { module = "io.ktor:ktor-server-cors-jvm", version.ref = "ktor" }Open the server module 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) implementation(libs.ktor.server.cors.jvm) }Once again,
in the main menu. After the import finishes, you should find that the imports for theContentNegotiation
type andjson()
function work correctly.Rerun the server. You should find that the routes are reachable from the browser.
Navigate to http://0.0.0.0:8080/tasks and http://0.0.0.0:8080/tasks/byPriority/Medium to see server responses with tasks in the JSON format.
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 dependencies involved in this:
The core functionality for the Ktor Client.
Platform-specific engines to handle networking.
Support for content negotiation and serialization.
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-client-wasm = { module = "io.ktor:ktor-client-js-wasm-js", version.ref = "ktor"} ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }Navigate to composeApp/build.gradle.kts and add the following dependencies:
kotlin { //... 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) } wasmJsMain.dependencies { implementation(libs.ktor.client.wasm) } } }Once this is done you can add a
TaskApi
type for your clients to act as a thin wrapper around the Ktor Client.Select
in the main menu to import the changes in the build file.Navigate to composeApp/src/commonMain/kotlin/com/example/ktor/full_stack_task_manager and create a new package called network.
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 to0.0.0.0
orlocalhost
from code running on an Android Virtual Device or the iOS Simulator.In the same composeApp/.../full_stack_task_manager/network package, create a new TaskApi.kt file with the following implementation:
package com.example.ktor.full_stack_task_manager.network import com.example.ktor.full_stack_task_manager.model.Task 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) } } }Navigate to commonMain/.../App.kt and replace the App composable with the implementation below. 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:package com.example.ktor.full_stack_task_manager import com.example.ktor.full_stack_task_manager.network.TaskApi import com.example.ktor.full_stack_task_manager.network.createHttpClient import com.example.ktor.full_stack_task_manager.model.Task 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 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) } } } }While the server is running, test the iOS application by running the
run configuration.Click the Fetch Tasks button to display the list of tasks:
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>Run the Android application using the
run configuration. You should now find that your Android client will run as well:For the desktop client, you should assign dimensions and a title to the containing window. Open the file composeApp/src/desktopMain/.../main.kt and modify the code by changing the
title
and setting thestate
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() } }Run the desktop application using the
run configuration:Run the web client using the
run configuration:
Improve the UI
The clients are now communicating with the server, but this is hardly an attractive UI.
Open the App.kt file located in composeApp/src/commonMain/.../full_stack_task_manager and replace the existing
App
with theApp
andTaskCard
composables below:package com.example.ktor.full_stack_task_manager import com.example.ktor.full_stack_task_manager.network.TaskApi import com.example.ktor.full_stack_task_manager.model.Priority import com.example.ktor.full_stack_task_manager.model.Task 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.safeContentPadding 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 .safeContentPadding() .fillMaxSize() ) { 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 theLazyColumn
composable allows the user to scroll through tasks.Finally, a separate
TaskCard
composable is created, which in turn uses aCard
to display the details of eachTask
. Buttons have been added to delete and update the task.Rerun the client application — for example, the Android app. You can now scroll through tasks, view their details, and delete them:
Add update functionality
To complete the client, incorporate the functionality that would allow updating task details.
Navigate to the App.kt file in composeApp/src/commonMain/.../full_stack_task_manager.
Add the
UpdateTaskDialog
composable and necessary imports as shown below:import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.ui.graphics.Color import androidx.compose.ui.window.Dialog @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. Thedescription
andpriority
are placed withinTextField
composables so they can be updated. When the user presses the update button, it triggers theonConfirm()
callback.Update the
App
composable in the same file:@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 .safeContentPadding() .fillMaxSize() ) { 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 theonConfirm()
callback set to send a POST request to the server using theTaskApi
.Finally, when you are creating the
TaskCard
composables, you use theonUpdate()
callback to set thecurrentTask
state variable.Rerun the client application. You should now be able to update the details of each task by using the buttons.
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.