Ktorfit: Type-Safe Networking in KMP
Ktorfit is what you reach for when you want Retrofit's ergonomics — declare your API as an annotated Kotlin interface, get a generated implementation — but you're targeting Kotlin Multiplatform, where Retrofit can't go because it's Android-only. It layers a Retrofit-style annotation API on top of the Ktor client, which is the actual multiplatform HTTP engine. You write @GET, @POST, @Path, @Query, and @Body on interface methods; Ktorfit's KSP processor generates the implementation that calls Ktor under the hood. The result is one networking layer, written once in commonMain, running natively on Android, iOS, JVM, and web.
Having moved an Android app's Retrofit layer into a shared KMP module via Ktorfit, the thing that struck me is how little the interface changed. The annotations map almost one-to-one. What changes is what's underneath — Ktor's engine-per-platform model and its serialization/plugin system replacing OkHttp interceptors and converter factories. Understand that swap and the migration is mostly mechanical.
The interface looks like Retrofit
Here's a service declared with Ktorfit. If you've written Retrofit, this needs no explanation:
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): User
@GET("users")
suspend fun search(@Query("q") query: String): List<User>
@POST("users")
suspend fun create(@Body user: NewUser): User
}
You obtain an implementation from a Ktorfit instance built around a configured Ktor HttpClient:
val ktorfit = Ktorfit.Builder()
.httpClient(httpClient) // your configured Ktor client
.baseUrl("https://api.example.com/")
.build()
val api: UserApi = ktorfit.createUserApi() // generated extension
The createUserApi() extension is generated by KSP at build time — no reflection, which matters because Kotlin/Native (iOS) has limited reflection anyway. This is one more case where a KSP-based generator does the boilerplate that reflection would do at runtime elsewhere, trading a bit of build time for startup speed and native compatibility.
Engines: the per-platform part
Ktor client abstracts HTTP behind an HttpClientEngine, and you pick a concrete engine per source set. This is the only genuinely platform-specific piece:
// commonMain — configuration is shared
fun createHttpClient(engine: HttpClientEngine) = HttpClient(engine) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) { level = LogLevel.INFO }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
}
}
// androidMain
actual fun platformEngine(): HttpClientEngine = OkHttp.create()
// iosMain
actual fun platformEngine(): HttpClientEngine = Darwin.create()
// jvmMain
actual fun platformEngine(): HttpClientEngine = CIO.create()
Android typically uses the OkHttp engine (so you keep OkHttp's connection pooling and can still add interceptors), iOS uses Darwin (backed by NSURLSession), the JVM uses CIO or Java. Crucially, the client configuration — content negotiation, logging, timeouts, auth — lives in commonMain and is shared. Only the engine constructor is actual per platform, the same expect/actual seam that a SQLDelight driver uses. So the vast majority of your networking code is written once.
Serialization and configuration replace converters
The mental shift from Retrofit is where cross-cutting behavior lives:
- Serialization: instead of a Gson/Moshi converter factory, Ktor uses
ContentNegotiationwithkotlinx.serialization. Annotate your DTOs@Serializableand register thejsonblock once. It's multiplatform and reflection-free. - Interceptors → plugins: OkHttp interceptors become Ktor client plugins or request pipeline stages. Auth token injection, retries, and logging are
install(...)blocks or custom plugins on theHttpClient. - Threading: Ktor's suspend calls are already main-safe and dispatch their blocking work internally, so you don't wrap them in
withContext(Dispatchers.IO)— the same point I made about dispatchers and suspend libraries.
A concrete auth example — injecting a bearer token for every request — uses Ktor's Auth plugin rather than an interceptor:
install(Auth) {
bearer {
loadTokens { BearerTokens(tokenStore.access, tokenStore.refresh) }
refreshTokens {
val new = refreshCall(oldTokens?.refreshToken)
BearerTokens(new.access, new.refresh)
}
}
}
The refreshTokens block gives you token refresh for free — Ktor calls it on a 401 and retries. Wiring that by hand with OkHttp authenticators was always fiddly; Ktor's version is declarative.
Retrofit → Ktorfit migration checklist
For a team moving an Android networking layer to shared KMP:
- Move interfaces to
commonMain; the@GET/@POST/@Path/@Query/@Bodyannotations mostly transfer verbatim. - Replace converter factories with
ContentNegotiation+kotlinx.serialization; add@Serializableto DTOs. - Rebuild interceptors as Ktor plugins (
Auth,Logging, customHttpClientPlugins for retries/headers). - Add the Ktorfit KSP plugin and generate the
create*Api()extensions. - Provide an engine per platform via
expect/actual(OkHttp/Android, Darwin, CIO/Java). - Keep OkHttp on Android if you rely on its connection pool or existing interceptors — the OkHttp engine lets you.
The friction points are honest ones: kotlinx.serialization is stricter than Gson about unknown keys and nullability (configure ignoreUnknownKeys and use proper nullable types), and some exotic Retrofit converters have no direct equivalent. But for a normal REST client — typed DTOs, JSON, bearer auth, retries — the migration is a day or two, and what you get back is one networking layer serving every platform instead of an Android-only one you'd have to reimplement in Swift.
Why this beats hand-rolling Ktor calls
You can use the Ktor client directly without Ktorfit — client.get("users/$id").body(). For a handful of endpoints that's fine. But raw Ktor calls scatter URL strings and manual body handling across your codebase, and they lose the single-place-to-read contract that an annotated interface gives you. Ktorfit's interface is documentation: one file lists every endpoint, its parameters, and its return type, checked by the compiler. On any API surface beyond a few calls, that centralization is worth the extra dependency and the KSP step. It's the same reason typed query layers beat inline SQL — the abstraction is a readable, checkable contract, not just less typing.
Resources
- Ktor client documentation
- Ktor client engines
- Ktorfit (Jens Klingenberg)
- kotlinx.serialization guide
- Ktor client authentication
Frequently asked questions
What is Ktorfit and how does it relate to Ktor?
Ktorfit is a KSP-based library that lets you declare HTTP APIs as annotated Kotlin interfaces, Retrofit-style, and generates the implementation on top of the Ktor client. Ktor provides the actual multiplatform HTTP engine and features; Ktorfit provides the ergonomic, type-safe interface layer over it. Together they give KMP projects the Retrofit developer experience that Retrofit itself, being Android-only, can't offer.
Does Ktor need a different engine on each platform?
Yes. Ktor client abstracts HTTP behind an engine interface with platform implementations: OkHttp or Android on Android, Darwin on iOS, CIO or Java on the JVM, and JS on web. You select the engine per source set, and your Ktorfit interfaces and Ktor configuration stay in common code because they only depend on the client abstraction.
Is migrating from Retrofit to Ktorfit hard?
The annotation surface is intentionally close to Retrofit's, so interfaces with GET, POST, Path, Query, and Body annotations look nearly identical. The main differences are that Ktorfit uses Ktor's serialization and client configuration instead of converters and OkHttp interceptors, and it runs via KSP rather than reflection. For a typical service interface the migration is mostly mechanical.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →