Share Ktor networking, repositories, and use cases while Android and iOS keep control of their native ViewModels, UI state, threading, and cancellation.

Kotlin Multiplatform allows us to share networking and business logic between Android and iOS. When an API call is made from the shared Kotlin module to the native app there are a number of things we need to make a decision on.

Who creates the coroutine? Who handles where the work is executed? Will the ViewModel be the same for Android and iOS? How do we get the use cases from the shared module onto each platform?

The decisions here are more important than the actual HTTP client.

A good KMP architecture will not have all the layers shared between both platforms. We can leave the repositories and use cases in Kotlin, have Android use its Jetpack ViewModels and iOS use SwiftUI with its native Swift ViewModels. Both platforms will call the same business functions but each will have control over their own presentation lifecycle.

It’s important to make sure the work that needs to be consistent is shared.
Let’s look at how we can create this architecture using a simple example of fetching articles from a REST API.

1. Decide what belongs in shared code

Lets start with two native applications and one shared KMP module.

The Android and iOS applications will not need or have identical ViewModels. But they should access to the same business operation.

The shared module can own:

  • HTTP client configuration and API requests.
  • DTOs and serialization.
  • Repository implementations and data-to-domain mapping.
  • Use cases that represent application operations.
  • Shared validation, business rules, and error classification.

Android can use using Jetpack ViewModel, viewModelScope, and StateFlow. iOS may use SwiftUI, ObservableObject, @Published, and Swift concurrency.

This is one of the architecture options described in the Kotlin Multiplatform ViewModel documentation: sharing the repository and data layers while keeping platform-specific ViewModels and UI.

We should not try to create shared ViewModels as well just because we have a shared repository.

2. Build the networking and repository layers in KMP

We will use Ktor for HTTP requests in this article. Ktor supports multiplatform clients with platform-specific engines, including OkHttp on Android and Darwin on iOS. Most of the networking logic will be in commonMain, while engine specific configuration lives in the platform source set. Check the Ktor client engine documentation.

Lets take a look at the sample article dto and domain models. First, lets define the transport model and the domain model.

@Serializable
data class ArticleDto(
val id: String,
val title: String
)

data class Article(
val id: String,
val title: String
)

The DTO represents the API response. The domain model represents the actual information the application needs for its usecase.

They happen to contain the same fields here, but that doesn’t mean they are always the same type. Keeping them separate allows the API contract to evolve or change without forcing every consumer to change with it.

Next, lets define an interface for the API:

interface ArticleApi {
suspend fun fetchArticles(): List<ArticleDto>
}

The Ktor implementation handles the HTTP request and response deserialization:

class KtorArticleApi(
private val client: HttpClient
) : ArticleApi {
override suspend fun fetchArticles(): List<ArticleDto> {
return client.get("articles").body()
}
}

Here, we assume the client is configured with the API’s base URL and the JSON content-negotiation plugin.

The repository sits above the API and converts the response into domain objects.

class ArticleRepository(
private val api: ArticleApi
) {
suspend fun getArticles(): List<Article> {
return api.fetchArticles().map { dto ->
Article(
id = dto.id,
title = dto.title
)
}
}
}

This means we do not need to care about whether we’re calling from an Android or iOS environment. We do not need to reference any Activities, SwiftUI views or Platform specific ViewModels.

We also do not need to create a new coroutine. The suspended function is already running within the callers coroutine.

This gives us structure around our async operations. We will not have random coroutines created within the data layer.

3. Add a use case above the repository

A use case gives the application an entry point to a business operation.

class GetArticlesUseCase(
private val repository: ArticleRepository
) {
suspend fun execute(): List<Article> {
return repository.getArticles()
}
}

This appears to be a completely redundant class. It just makes a call to our repository.

If all our use cases are just additional classes with no real purpose then this is a valid point. We shouldn’t be making layers of code in this way.

However, if we have operations that are meaningful at an application level, the use case can become useful. For example, getting some articles may require us to check if any are in the cache, apply some business logic, combine the results of multiple repositories or determine what items are valid to show.

Our ViewModels shouldn’t care about how the operation gets the data, just what operation it needs.

The dependency direction becomes straightforward:

The shared module now provides a stable API to both mobile applications. The native ViewModels don’t need to know which Ktor engine is running, how response DTOs are mapped, or where the API base URL is configured.

4. Construct the dependency graph once

A common dependency will not add much value if each platform has its own separate networking stack.

This module should provide a common dependency graph. Using a dependency injection framework that works on different platforms (like Koin) can help us accomplish this.

A simplified common module might look like this:

val sharedModule = module {

single { createPlatformHttpClient() }

single<ArticleApi> {
KtorArticleApi(get())
}

single {
ArticleRepository(get())
}

factory {
GetArticlesUseCase(get())
}

}

In this example we have a createPlatformHttpClient() platform specific function that returns a configured HttpClient. On Android this could be OkHttp whilst on iOS it could be Darwin.

We are using single to provide a shared long lived dependency and factory for our use case. This is a choice and the actual lifespan should be based on how the dependency is owned and what state is required, and not treated as universal rules.

Normally you would want to share an instance of your HTTP client.

On Android the native ViewModel can be provided with an instance of GetArticlesUseCase via the applications DI. On iOS the shared use case can be obtained by the application using a small Kotlin interop facade.

One practical detail: Swift cannot directly call Koin’s reified get<T>() API in the same way Kotlin can. Koin's KMP integration guide documents the use of a Kotlin helper implementing KoinComponent to expose dependencies to Swift.

For example, an iOS-specific helper can expose the use case:

class IosDependencies : KoinComponent {
val getArticles: GetArticlesUseCase by inject()
}

Initialize Koin before creating this helper. The Swift ViewModel can then receive getArticles through its initializer.

The important point is that dependency injection happens at the application level; the use case itself remains unaware of the platform that requested it.

5. Android: let viewModelScope own the request lifecycle

The Android ViewModel receives the shared use case through constructor injection.

class ArticlesViewModel(
private val getArticles: GetArticlesUseCase
) : ViewModel() {

private val _uiState =
MutableStateFlow<ArticlesUiState>(
ArticlesUiState.Loading
)

val uiState: StateFlow<ArticlesUiState> =
_uiState.asStateFlow()

fun loadArticles() {
viewModelScope.launch {
_uiState.value = ArticlesUiState.Loading
try {
val articles = getArticles.execute()
_uiState.value =
ArticlesUiState.Content(articles)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Exception) {
_uiState.value = ArticlesUiState.Error
}
}
}

}

ArticlesUiState data class having loading, content and error states specific to our app.

The coroutine is launched from our ViewModel.

viewModelScope scopes this operation to the ViewModel’s lifecycle. When the ViewModel is cleared we will cancel our scope. Our shared use case does not need to create a new scope or have any knowledge of the Android life cycle.

ViewModel is also responsible for mapping our result to presentation state. We observe the uiState from the UI and do not need any knowledge of the responses from our API or repository implementation.

Catch cancellation is deliberate. We do not want to convert CancellationException to a normal network failure. Structured concurrency uses cancellations to notify us that we do not need to perform this work.

In a production application we would want to handle the error flow by translating any known network / domain failures to the correct UI state or localized message.

This follows the principles in Android’s coroutine best practices: ViewModels initiate work, repositories provide main-safe suspend functions, and UI state is exposed as an observable stream.

6. iOS: keep the ViewModel native and bridge the suspend function

The iOS application can use the same shared GetArticlesUseCase without adopting Kotlin ViewModels or Compose Multiplatform.

A native SwiftUI ViewModel can get the shared dependency through its initializer:

import SwiftUI
import KMPNativeCoroutinesAsync

@MainActor
final class ArticlesViewModel: ObservableObject {

@Published private(set) var state: ArticlesState = .loading

private let getArticles: GetArticlesUseCase

private var loadTask: Task<Void, Never>?

init(getArticles: GetArticlesUseCase) {
self.getArticles = getArticles
}

func loadArticles() {
loadTask?.cancel()

loadTask = Task {
state = .loading
do {
let articles = try await asyncFunction(
for: getArticles.execute()
)
guard !Task.isCancelled else { return }
state = .loaded(
articles.map {
ArticleRow(id: $0.id, title: $0.title)
}
)
} catch is CancellationError {
return
} catch {
state = .failed("Unable to load articles")
}
}
}

deinit {
loadTask?.cancel()
}

}

ArticlesState and ArticleRow are Swift presentation types; the latter illustrates mapping the shared domain model into a type suited to the SwiftUI view.

The coroutine bridge matters here. Kotlin suspend functions and Flow types don't automatically provide the full Swift concurrency experience, especially around cancellation and typed streams. KMP-NativeCoroutines provides generated wrappers that allow suspend functions to be called with Swift async/await, with cancellation propagated through the bridge. SKIE is another option for improving Kotlin-to-Swift coroutine interoperability.

In a KMP-NativeCoroutines setup, annotate the suspend function exposed to Swift with @NativeCoroutines and configure the Kotlin and Swift dependencies. The exact generated symbol names depend on the interop setup and plugin version; the example above illustrates the call pattern.

There are two important decisions in this ViewModel.

First, @MainActor protects presentation state. Mutations of state belong to the main actor, where SwiftUI observes them. The network operation itself is asynchronous, so awaiting it doesn't require blocking the main thread.

Second, the Swift Task gives the ViewModel a handle for cancelling the request when another load replaces it or the ViewModel is discarded. Cancellation only works as intended when it propagates through the coroutine bridge and the underlying operation cooperates.

The iOS ViewModel doesn’t need to know which HTTP engine Ktor uses. It only needs a callable shared use case and a defined way to observe its completion.

Note: Swift interoperability can introduce generated types, naming differences, and collection conversions. Keep that complexity near the interop boundary rather than spreading workarounds throughout the SwiftUI code.

7. The threading decision: don’t add dispatchers just because the code is shared

This is the part that tends to cause unnecessary complexity.

Suppose the Android ViewModel starts its coroutine on the main dispatcher:

viewModelScope.launch {
val articles = getArticles.execute()
}

Does that mean the network request runs as blocking work on the main thread?

Not when the underlying operation is properly asynchronous and main-safe.

The request functions in Ktor are suspending functions. This means the function can be suspended whilst it waits for the network without blocking the thread(maybe main)that initiated the request. Once a response is available, the coroutine will resume using its coroutine context.

That means wrapping every Ktor call in withContext(Dispatchers.IO) is not a requirement.

The better rule is to assign responsibility to the layer that knows what kind of work it performs.

Networking I/O

Use suspending APIs for standard Ktor requests. The client engine will take care of the actual network call. Don’t add a dispatcher switch just because you’re crossing a layer boundary.

Blocking I/O

When calling a truly blocking API from a repository, ensure that this doesn’t block the callers thread. Offload this to a suitable injected dispatcher for the target platform.

CPU-intensive work

Major transformations, expensive calculations, or other CPU-heavy processing may need a background dispatcher such as an injected Dispatchers.Default dispatcher.

UI state changes

Android can update the state of our ViewModel using viewModelScope on the main dispatcher. The state in SwiftUI should be on the correct main actor. We should not manipluate the UI state of either platform directly from shared business logic.

It is important to note that having suspend does not mean your function will not block. You can still call blocking APIs from within a suspend function. The main safety comes down to the implementation.

This is why dispatcher policy should be within the code doing the work. Usually we will not need to specify any dispatchers for use cases that orchestrate main safe repositories.

For additional guidance, the Android coroutine best practices explain why suspend functions should be main-safe and why dispatchers should be injected when a class needs to switch execution contexts.

8. Common mistakes worth avoiding

The architecture is simple, but a few implementation choices can undermine it.

Creating a new HTTP client for each request. Reuse a configured client so connection management, plugins, timeouts, and other settings remain consistent.

Launching independent scopes in repositories. When a repository starts background coroutines and returns immediately, callers lose straightforward control over completion and cancellation. Prefer suspending functions whose lifecycle is controlled by the caller.

Catching every exception as a normal failure. Always preserve coroutine cancellation. Also distinguish expected network failures from programming errors where practical.

Mixing DTOs with UI models everywhere. Mapping the network response to a domain model in the shared repository, and then mapping the domain model to a platform presentation model when needed, keeps API changes from spreading across the application.

Making the shared module responsible for native presentation. Unless the project deliberately uses a shared ViewModel architecture, shared networking code shouldn’t need references to Android ViewModels, SwiftUI state, or native view lifecycles.

Adding use cases without meaningful responsibilities. A use case should name an application operation or provide a useful place for business orchestration. It shouldn’t exist only to increase the number of classes.

These are not rules that every project must implement identically. They’re checks for preserving clear ownership and keeping the dependency graph understandable.

9. Test the shared logic and the native presentation separately

This separation also gives the project a useful testing strategy.

In commonTest, test repository mapping, use-case behavior, and error handling using fake API or repository implementations. You can verify shared rules without launching Android or iOS.

On Android, we should unit-test the ViewModel’s state transitions and cancellation behavior. We may use coroutine test utilities and a controlled dispatcher.

On iOS, we should test the Swift ViewModel’s loading, success, failure, and cancellation behavior. This must be done independently from the SwiftUI view.

We should also test the Kotlin-to-Swift bridge where its generated API and cancellation behavior create integration risks.

A common unit test will not ensure the HTTP engine on each platform have been set up correctly.
Android request may be successful, but this will not ensure the iOS engine has been set up along with the dependencies and Swift concurrency bridge.

We can keep these tests separate.

The architecture to aim for

Kotlin Multiplatform gives you a choice about where code is shared. It doesn’t require a single architecture to be imposed on every platform.

For a project that retains native Android and iOS interfaces, a practical design is:

  • KMP: Ktor networking, serialization, repositories, domain models, and use cases.
  • Android: Native ViewModels, viewModelScope, and observable UI state.
  • iOS: SwiftUI ViewModels, Swift concurrency, and main-actor-isolated presentation state.
  • Dependency injection: One shared dependency graph, with small platform-specific entry points where needed.
  • Threading: Main-safe suspend functions, explicit dispatchers for blocking or CPU-heavy work, and cancellation that follows the operation’s lifecycle.

The key decision here is that there must be clear owners for each of your layers. Having owners makes it much easier to re-use modules.

The module containing the API can be owned by the shared module and Android and iOS can use this behaviour while still keeping the native lifecycle, UI and presentation models. This makes threading much easier to understand as we know the ViewModel will own the request lifecycle, the data layer will own the work and the UI simply observes.

This is a much more useful way to share code.


Kotlin Multiplatform Networking with Native Android and iOS ViewModels was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.