Ktor Server Development

Introduction
Ktor is a Kotlin-first framework for building asynchronous servers and clients. It's designed to be lightweight, flexible, and easy to use, making it an excellent choice for Android developers who want to build backend services using the same language they use for mobile development.
This comprehensive guide will take you from basic Ktor server concepts to advanced features like WebSockets, authentication, database integration, and deployment. Whether you're building a simple REST API for your Android app or a complex real-time messaging system, Ktor provides the tools and patterns you need for modern backend development.
Why Choose Ktor?
- Kotlin First: Built specifically for Kotlin with coroutines support
- Lightweight: Minimal overhead and fast startup times
- Modular: Plugin-based architecture for flexibility
- Asynchronous: Built on Kotlin coroutines for high performance
- WebSocket Support: Native WebSocket support for real-time communication
- Multiplatform: Works on JVM, JavaScript, and Native
- Easy Testing: Built-in testing support
- Modern Architecture: Designed for modern application patterns
Setting Up Ktor
Project Setup
// build.gradle.kts
plugins {
kotlin("jvm") version "1.9.0"
kotlin("plugin.serialization") version "1.9.0"
id("io.ktor.plugin") version "2.3.0"
}
dependencies {
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-netty")
implementation("io.ktor:ktor-server-content-negotiation")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-auth-jwt")
implementation("io.ktor:ktor-server-cors")
implementation("io.ktor:ktor-server-call-logging")
// Database
implementation("org.jetbrains.exposed:exposed-core:0.41.1")
implementation("org.jetbrains.exposed:exposed-dao:0.41.1")
implementation("org.jetbrains.exposed:exposed-jdbc:0.41.1")
implementation("org.postgresql:postgresql:42.6.0")
// Testing
testImplementation("io.ktor:ktor-server-tests")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
}
Main Application
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
configureSerialization()
configureDatabases()
configureRouting()
configureAuthentication()
configureCORS()
configureLogging()
}.start(wait = true)
}
fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
}
fun Application.configureRouting() {
routing {
route("/api") {
userRoutes()
authRoutes()
}
}
}
REST API Development
Data Models
@Serializable
data class User(
val id: Int? = null,
val email: String,
val name: String,
val createdAt: String? = null
)
@Serializable
data class CreateUserRequest(
val email: String,
val name: String,
val password: String
)
@Serializable
data class LoginRequest(
val email: String,
val password: String
)
@Serializable
data class AuthResponse(
val token: String,
val user: User
)
Route Definitions
fun Route.userRoutes() {
route("/users") {
get {
val users = userService.getAllUsers()
call.respond(users)
}
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, "Invalid ID")
val user = userService.getUserById(id)
?: return@get call.respond(HttpStatusCode.NotFound, "User not found")
call.respond(user)
}
post {
val request = call.receive()
val user = userService.createUser(request)
call.respond(HttpStatusCode.Created, user)
}
put("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, "Invalid ID")
val request = call.receive()
val user = userService.updateUser(id, request)
?: return@put call.respond(HttpStatusCode.NotFound, "User not found")
call.respond(user)
}
delete("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: return@delete call.respond(HttpStatusCode.BadRequest, "Invalid ID")
val deleted = userService.deleteUser(id)
if (deleted) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, "User not found")
}
}
}
}
Database Integration
Database Configuration
fun Application.configureDatabases() {
val database = Database.connect(
url = environment.config.property("database.url").getString(),
driver = "org.postgresql.Driver",
user = environment.config.property("database.user").getString(),
password = environment.config.property("database.password").getString()
)
install(Databases) {
database("main", database)
}
}
// Database tables
object Users : Table("users") {
val id = integer("id").autoIncrement()
val email = varchar("email", 255).uniqueIndex()
val name = varchar("name", 255)
val passwordHash = varchar("password_hash", 255)
val createdAt = datetime("created_at")
override val primaryKey = PrimaryKey(id)
}
Service Layer
class UserService {
suspend fun getAllUsers(): List = withContext(Dispatchers.IO) {
transaction {
Users.selectAll().map { rowToUser(it) }
}
}
suspend fun getUserById(id: Int): User? = withContext(Dispatchers.IO) {
transaction {
Users.select { Users.id eq id }
.singleOrNull()
?.let { rowToUser(it) }
}
}
suspend fun createUser(request: CreateUserRequest): User = withContext(Dispatchers.IO) {
transaction {
val passwordHash = hashPassword(request.password)
val now = Clock.System.now()
val id = Users.insertAndGetId {
it[email] = request.email
it[name] = request.name
it[passwordHash] = passwordHash
it[createdAt] = now
}
User(
id = id.value,
email = request.email,
name = request.name,
createdAt = now.toString()
)
}
}
suspend fun updateUser(id: Int, request: CreateUserRequest): User? = withContext(Dispatchers.IO) {
transaction {
val updatedRows = Users.update({ Users.id eq id }) {
it[email] = request.email
it[name] = request.name
}
if (updatedRows > 0) {
getUserById(id)
} else {
null
}
}
}
suspend fun deleteUser(id: Int): Boolean = withContext(Dispatchers.IO) {
transaction {
Users.deleteWhere { Users.id eq id } > 0
}
}
private fun rowToUser(row: ResultRow): User = User(
id = row[Users.id],
email = row[Users.email],
name = row[Users.name],
createdAt = row[Users.createdAt].toString()
)
private fun hashPassword(password: String): String {
return BCrypt.hashpw(password, BCrypt.gensalt())
}
}
Authentication
JWT Configuration
fun Application.configureAuthentication() {
install(Authentication) {
jwt("auth-jwt") {
realm = "Access to the '/api' path"
verifier(JwtConfig.verifier)
validate { credential ->
val payload = credential.payload
val email = payload.getClaim("email", String::class)
val user = userService.getUserByEmail(email)
user
}
}
}
}
object JwtConfig {
private const val secret = "your-secret-key-here"
private const val issuer = "your-app"
private const val validityInMs = 36_000_00 * 24 // 24 hours
val verifier = JWT.require(Algorithm.HMAC256(secret))
.withIssuer(issuer)
.build()
fun makeToken(user: User): String = JWT.create()
.withSubject("Authentication")
.withIssuer(issuer)
.withClaim("email", user.email)
.withExpiresAt(getExpiration())
.sign(Algorithm.HMAC256(secret))
private fun getExpiration() = Date(System.currentTimeMillis() + validityInMs)
}
Auth Routes
fun Route.authRoutes() {
route("/auth") {
post("/register") {
val request = call.receive()
val user = userService.createUser(request)
val token = JwtConfig.makeToken(user)
call.respond(AuthResponse(token, user))
}
post("/login") {
val request = call.receive()
val user = userService.authenticateUser(request.email, request.password)
?: return@post call.respond(HttpStatusCode.Unauthorized, "Invalid credentials")
val token = JwtConfig.makeToken(user)
call.respond(AuthResponse(token, user))
}
authenticate("auth-jwt") {
get("/me") {
val user = call.principal()
call.respond(user!!)
}
}
}
}
WebSocket Support
WebSocket Route
fun Route.websocketRoutes() {
route("/ws") {
webSocket("/chat") {
val sessionId = call.parameters["sessionId"]
val user = call.principal()
if (user == null) {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Authentication required"))
return@webSocket
}
// Add to active sessions
activeSessions[sessionId] = this
try {
for (frame in incoming) {
when (frame) {
is Frame.Text -> {
val text = frame.readText()
val message = Json.decodeFromString(text)
// Broadcast to all connected clients
activeSessions.values.forEach { session ->
session.outgoing.send(Frame.Text(text))
}
}
is Frame.Close -> {
activeSessions.remove(sessionId)
break
}
}
}
} catch (e: Exception) {
activeSessions.remove(sessionId)
}
}
}
}
@Serializable
data class ChatMessage(
val userId: Int,
val message: String,
val timestamp: Long = System.currentTimeMillis()
)
Error Handling
fun Application.configureStatusPages() {
install(StatusPages) {
exception { call, cause ->
call.respond(HttpStatusCode.Unauthorized, ErrorResponse("UNAUTHORIZED", cause.message))
}
exception { call, cause ->
call.respond(HttpStatusCode.Forbidden, ErrorResponse("FORBIDDEN", cause.message))
}
exception { call, cause ->
call.respond(HttpStatusCode.NotFound, ErrorResponse("NOT_FOUND", cause.message))
}
exception { call, cause ->
call.respond(HttpStatusCode.BadRequest, ErrorResponse("VALIDATION_ERROR", cause.message))
}
exception { call, cause ->
call.application.log.error("Unhandled error", cause)
call.respond(HttpStatusCode.InternalServerError, ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"))
}
}
}
@Serializable
data class ErrorResponse(
val error: String,
val message: String
)
Configuration
# application.conf
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ com.example.ApplicationKt.module ]
}
}
database {
url = "jdbc:postgresql://localhost:5432/ktor_app"
user = "postgres"
password = "password"
}
jwt {
secret = "your-secret-key-here"
issuer = "your-app"
validityInMs = 86400000
}
logging {
level = INFO
}
Testing
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
configureRouting()
}
client.get("/api/users").apply {
assertEquals(HttpStatusCode.OK, status)
}
}
@Test
fun testUserCreation() = testApplication {
application {
configureRouting()
configureSerialization()
}
val request = CreateUserRequest(
email = "test@example.com",
name = "Test User",
password = "password123"
)
client.post("/api/users") {
contentType(ContentType.Application.Json)
setBody(request)
}.apply {
assertEquals(HttpStatusCode.Created, status)
}
}
}
Deployment
Docker Configuration
FROM gradle:7.6.1-jdk17 AS build
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon
FROM openjdk:17-jdk-slim
EXPOSE 8080:8080
RUN mkdir /app
COPY --from=build /home/gradle/src/build/libs/*.jar /app/ktor-app.jar
ENTRYPOINT ["java","-jar","/app/ktor-app.jar"]
Docker Compose
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
environment:
- DATABASE_URL=jdbc:postgresql://db:5432/ktor_app
- DATABASE_USER=postgres
- DATABASE_PASSWORD=password
depends_on:
- db
db:
image: postgres:13
environment:
- POSTGRES_DB=ktor_app
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Best Practices
- Use Coroutines: Leverage Kotlin coroutines for asynchronous operations
- Structure Routes: Organize routes in separate functions for maintainability
- Handle Errors: Implement comprehensive error handling
- Validate Input: Always validate incoming requests
- Use DTOs: Separate API models from database models
- Implement Logging: Add proper logging throughout the application
- Security First: Implement proper authentication and authorization
- Test Thoroughly: Write comprehensive tests for all endpoints
Practice Exercises
Exercise 1: Real-time Chat Application
// Create a real-time chat application with:
// - User authentication and registration
// - WebSocket-based messaging
// - Message persistence
// - Online user status
// - Private messaging
Exercise 2: File Upload Service
// Build a file upload service with:
// - File upload endpoints
// - File storage and retrieval
// - Image processing and thumbnails
// - Access control and sharing
// - Progress tracking
Exercise 3: Notification Service
// Create a notification service with:
// - Push notification endpoints
// - WebSocket for real-time notifications
// - Notification preferences
// - Delivery status tracking
// - Multi-platform support
Next Steps
- Microservices: Break down applications into microservices
- Message Queues: Implement asynchronous processing with Kafka/RabbitMQ
- Caching: Use Redis for performance optimization
- GraphQL: Implement GraphQL APIs for flexible data fetching
- Cloud Deployment: Deploy to AWS, Google Cloud, or Azure
- Monitoring: Implement application monitoring and metrics
Resources
Summary
Ktor is a powerful and flexible framework for building backend services using Kotlin. Its coroutine-based architecture, modular design, and excellent WebSocket support make it an ideal choice for Android developers who want to build modern, scalable backend services.
You've learned about Ktor setup, REST API development, database integration, authentication, WebSocket support, error handling, testing, and deployment. Remember to follow best practices, implement proper security measures, and leverage Kotlin's coroutines for optimal performance.
Practice building different types of backend services, experiment with WebSockets and real-time features, and stay updated with the latest Ktor patterns and best practices. The more you work with Ktor, the more you'll appreciate its power and flexibility for building modern backend systems.