You have built a complete Ktor backend with authentication, database, WebSockets, Docker, and CI/CD. Now it is time to connect everything — a Kotlin backend and a Kotlin client sharing the same data models.
In this final tutorial, you will create shared data models, build a Ktor Client that consumes your API, and see how a KMP mobile app connects to your backend.
The Full-Stack Kotlin Vision
Most full-stack projects look like this:
Backend (Python/Node.js) → JSON → Frontend (TypeScript/JavaScript)
You define models on both sides. You keep them in sync manually. When the API changes, the client breaks.
With Kotlin, you can share models:
Backend (Ktor Server) → Shared Kotlin Module → Client (Ktor Client / KMP)
Same @Serializable data classes. Same validation. One language. When you change a model, both sides update together.
Project Structure
In a real full-stack Kotlin project, you have three Gradle modules:
project/
├── shared/ ← Shared DTOs (used by server + client)
│ └── src/commonMain/kotlin/models/
├── server/ ← Ktor Server (this series)
│ └── src/main/kotlin/
└── client/ ← KMP Client (Android, iOS, Desktop)
└── src/commonMain/kotlin/
For this tutorial, we keep everything in one module to keep it simple. The shared models live in com.kemalcodes.shared. In production, move them to a separate Gradle module.
Shared Data Models
These @Serializable classes are used by both the server and the client:
package com.kemalcodes.shared
import kotlinx.serialization.Serializable
// --- Auth DTOs ---
@Serializable
data class SharedLoginRequest(
val email: String,
val password: String
)
@Serializable
data class SharedRegisterRequest(
val name: String,
val email: String,
val password: String
)
@Serializable
data class SharedTokenResponse(
val accessToken: String,
val refreshToken: String,
val expiresIn: Long = 3600
)
@Serializable
data class SharedRefreshRequest(
val refreshToken: String
)
// --- Note DTOs ---
@Serializable
data class SharedNoteResponse(
val id: Int,
val title: String,
val content: String,
val userId: Int? = null,
val authorName: String? = null,
val tags: List<String> = emptyList(),
val createdAt: String? = null
)
@Serializable
data class SharedCreateNoteRequest(
val title: String,
val content: String,
val userId: Int? = null,
val tags: List<String> = emptyList()
)
// --- User DTOs ---
@Serializable
data class SharedUserProfile(
val id: Int,
val name: String,
val email: String,
val role: String
)
@Serializable
data class SharedErrorResponse(
val message: String,
val code: Int
)
Why “Shared” Prefix?
The server already has LoginRequest, NoteResponse, and other models for its internal use. The “Shared” prefix distinguishes the shared DTOs from server-internal models.
In a real multi-module project, you would not need the prefix. The shared module has its own package, and imports resolve unambiguously.
Why Separate from Server Models?
Server models might include database-specific fields, internal IDs, or audit columns that the client should never see. Shared models define the API contract — what the client sends and receives.
Server Model (internal):
id, title, content, userId, createdAt, updatedAt, deletedAt, version
Shared Model (API contract):
id, title, content, userId, authorName, tags, createdAt
The Ktor Client
Ktor Client is the HTTP client counterpart to Ktor Server. It uses the same coroutines, serialization, and plugin system.
package com.kemalcodes.client
import com.kemalcodes.shared.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class KtorApiClient(
private val baseUrl: String = "http://localhost:8080",
private val httpClient: HttpClient = createDefaultClient()
) {
private var accessToken: String? = null
// --- Auth ---
suspend fun register(
name: String,
email: String,
password: String
): SharedTokenResponse {
val response = httpClient.post("$baseUrl/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(SharedRegisterRequest(name, email, password))
}.body<SharedTokenResponse>()
accessToken = response.accessToken
return response
}
suspend fun login(email: String, password: String): SharedTokenResponse {
val response = httpClient.post("$baseUrl/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(SharedLoginRequest(email, password))
}.body<SharedTokenResponse>()
accessToken = response.accessToken
return response
}
suspend fun getProfile(): SharedUserProfile {
return httpClient.get("$baseUrl/api/auth/me") {
bearerAuth(accessToken
?: throw IllegalStateException("Not logged in"))
}.body()
}
suspend fun refreshToken(refreshToken: String): SharedTokenResponse {
val response = httpClient.post("$baseUrl/api/auth/refresh") {
contentType(ContentType.Application.Json)
setBody(SharedRefreshRequest(refreshToken))
}.body<SharedTokenResponse>()
accessToken = response.accessToken
return response
}
// --- Notes ---
suspend fun getNotes(
page: Int = 1,
size: Int = 10
): List<SharedNoteResponse> {
return httpClient.get("$baseUrl/api/notes") {
parameter("page", page)
parameter("size", size)
}.body()
}
suspend fun getNote(id: Int): SharedNoteResponse {
return httpClient.get("$baseUrl/api/notes/$id").body()
}
suspend fun createNote(
title: String,
content: String,
tags: List<String> = emptyList()
): SharedNoteResponse {
return httpClient.post("$baseUrl/api/notes") {
contentType(ContentType.Application.Json)
setBody(SharedCreateNoteRequest(title, content, tags = tags))
}.body()
}
fun close() {
httpClient.close()
}
companion object {
fun createDefaultClient(): HttpClient {
return HttpClient(io.ktor.client.engine.java.Java) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
})
}
}
}
}
}
How It Works
The client mirrors the server API. Each server endpoint has a corresponding client method:
| Server Endpoint | Client Method |
|---|---|
POST /api/auth/register | register() |
POST /api/auth/login | login() |
GET /api/auth/me | getProfile() |
POST /api/auth/refresh | refreshToken() |
GET /api/notes | getNotes() |
GET /api/notes/{id} | getNote() |
POST /api/notes | createNote() |
Token Management
private var accessToken: String? = null
suspend fun login(email: String, password: String): SharedTokenResponse {
val response = httpClient.post("$baseUrl/api/auth/login") {
// ...
}.body<SharedTokenResponse>()
accessToken = response.accessToken // Store token
return response
}
suspend fun getProfile(): SharedUserProfile {
return httpClient.get("$baseUrl/api/auth/me") {
bearerAuth(accessToken ?: throw IllegalStateException("Not logged in"))
}.body()
}
After login, the client stores the access token. Subsequent requests include it as a Bearer token. In a real app, you would store the token securely (Keychain on iOS, EncryptedSharedPreferences on Android).
Platform-Specific Engines
The example uses Java engine because we are on JVM. In a KMP project, each platform uses its own engine:
// Android
HttpClient(OkHttp) { /* config */ }
// iOS
HttpClient(Darwin) { /* config */ }
// Desktop/JVM
HttpClient(Java) { /* config */ }
The API is identical. Only the engine changes. This is covered in KMP Tutorial #6: Networking with Ktor Client.
Using the Client
suspend fun main() {
val client = KtorApiClient("http://localhost:8080")
// Register
val tokens = client.register("Alex", "alex@example.com", "password123")
println("Access token: ${tokens.accessToken}")
// Get profile
val profile = client.getProfile()
println("Welcome, ${profile.name}!")
// Create a note
val note = client.createNote(
title = "My First Note",
content = "Hello from the client!",
tags = listOf("kotlin", "ktor")
)
println("Created note: ${note.title}")
// List notes
val notes = client.getNotes()
println("Total notes: ${notes.size}")
client.close()
}
Moving to a Shared Gradle Module
In production, extract shared models into a separate Gradle module:
// settings.gradle.kts
include(":shared", ":server", ":client")
// shared/build.gradle.kts
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
}
kotlin {
jvm() // Used by server
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
}
}
}
}
// server/build.gradle.kts
dependencies {
implementation(project(":shared"))
// ... ktor server deps
}
// client/build.gradle.kts (KMP)
kotlin {
sourceSets {
commonMain {
dependencies {
implementation(project(":shared"))
// ... ktor client deps
}
}
}
}
Now both server and client depend on :shared. When you add a field to SharedNoteResponse, both sides see the change immediately. If you forget to update the server, the compiler catches it.
API Contract Testing
With shared models, type safety prevents most mismatches. But you should still test the full API contract:
@Test
fun `server response matches shared model`() = testApplication {
application { module() }
val client = jsonClient()
// Create with shared model
val response = client.post("/api/notes") {
contentType(ContentType.Application.Json)
setBody(SharedCreateNoteRequest("Test", "Content"))
}
// Deserialize with shared model
val note = response.body<SharedNoteResponse>()
assertEquals("Test", note.title)
assertNotNull(note.id)
}
This test verifies that the server produces responses that match the shared model. If the server adds a required field that the shared model does not have, the test fails.
API Versioning
When you change the API, old clients break. Version your API from the start:
route("/api/v1") {
noteRoutes(noteRepository)
authRoutes(userRepository, refreshTokenRepository)
}
When you make breaking changes, create a new version:
route("/api/v1") { /* old endpoints */ }
route("/api/v2") { /* new endpoints */ }
Keep the old version running until all clients update. Shared models help here — you can have v1 and v2 model variants in the shared module.
Offline-First Pattern
Mobile apps need to work without internet. The pattern:
- Store data locally (SQLDelight — covered in KMP Tutorial #7)
- Show local data immediately
- Sync with the server in the background
- Handle conflicts when both local and remote data changed
Client reads: Local DB → Display
Client writes: Local DB → Sync Queue → Server
Server responds: Server → Local DB → Display updated
This is a complex topic. The key insight: your Ktor API client becomes one part of a larger data layer that includes local storage and sync logic.
Architecture Diagram
Here is the complete full-stack architecture:
┌─────────────────────────────────────────────┐
│ KMP Client │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ UI Layer │──│ViewModel │──│ Repository│ │
│ └──────────┘ └──────────┘ └───────────┘ │
│ │ │ │
│ ┌──────┘ └────┐ │
│ ┌─────┴───┐ ┌───────┴┐ │
│ │ SQLDelight│ │ Ktor │ │
│ │ (local) │ │ Client │ │
│ └─────────┘ └────────┘ │
└─────────────────────────────────┬───────────┘
│ HTTPS
┌─────────────────────────────────┴───────────┐
│ Ktor Server │
│ ┌────────┐ ┌──────────┐ ┌────────────┐ │
│ │ Routes │──│ Services │──│ Repositories│ │
│ └────────┘ └──────────┘ └────────────┘ │
│ │ │
│ ┌───────┴────────┐ │
│ │ PostgreSQL │ │
│ └────────────────┘ │
└──────────────────────────────────────────────┘
Shared Module: @Serializable DTOs
What We Built in This Series
Over 22 tutorials, you built a production-ready Kotlin backend:
| Tutorial | What You Learned |
|---|---|
| #1-2 | What is Ktor, Ktor vs Spring Boot |
| #3-5 | Project setup, routing, serialization |
| #6-9 | Database with Exposed, CRUD, relationships, file uploads |
| #10 | Database migrations with Flyway |
| #11-14 | JWT auth, registration/login, OAuth, security |
| #15-17 | WebSockets, OpenAPI, HTMX |
| #18-19 | Dependency injection, testing |
| #20-22 | Docker, CI/CD, full-stack integration |
What to Learn Next
Your Ktor foundation is solid. Here are paths to explore:
Scaling:
- Kubernetes for container orchestration
- Horizontal scaling with multiple Ktor instances
- Redis for caching and session storage
Advanced APIs:
- GraphQL with Ktor (KGraphQL or graphql-kotlin)
- gRPC for high-performance service-to-service communication
- Server-Sent Events for real-time notifications
Architecture:
- Microservices with Ktor
- Event-driven architecture with Kafka
- CQRS and Event Sourcing patterns
KMP Integration:
- Build the mobile client from KMP Tutorial series
- Share business logic between server and client
- Implement offline-first sync
Error Handling on the Client
Network requests fail. The client should handle errors gracefully:
suspend fun getNotesOrEmpty(): List<SharedNoteResponse> {
return try {
getNotes()
} catch (e: Exception) {
emptyList() // Return empty list on error
}
}
For better error handling, use Ktor’s HttpResponse status code:
suspend fun login(email: String, password: String): Result<SharedTokenResponse> {
val response = httpClient.post("$baseUrl/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(SharedLoginRequest(email, password))
}
return when (response.status) {
HttpStatusCode.OK -> Result.success(response.body())
HttpStatusCode.Unauthorized -> Result.failure(Exception("Invalid credentials"))
else -> Result.failure(Exception("Server error: ${response.status}"))
}
}
The Result type lets the caller handle success and failure without try-catch. This pattern integrates well with ViewModel architecture in KMP apps.
Common Mistakes
Duplicating data classes — Share models through a Gradle module instead of copy-pasting classes between server and client.
Not versioning the API — Breaking changes without versioning break all existing clients.
Breaking client compatibility — Adding a required field to a response breaks old clients. Make new fields optional with default values.
Exposing server internals — Shared models should contain only what the client needs. Never expose database IDs, internal timestamps, or audit fields.
Ignoring the offline case — Mobile users lose connectivity. Design your API and client to handle network failures gracefully.
Source Code
You can find the source code for this tutorial on GitHub:
github.com/kemalcodes/ktor-tutorial — Branch: tutorial-22-fullstack
Related Articles
- Ktor Tutorial #21: CI/CD and Deployment — Deploy your backend
- Ktor Tutorial #11: JWT Authentication — Auth system the client uses
- KMP Tutorial #6: Networking with Ktor Client — Ktor Client in KMP
- KMP Tutorial #7: SQLDelight — Local storage for offline-first
- KMP Tutorial #10: Shared ViewModel — ViewModel architecture