The Kotlin-First Backend: Creating Scalable APIs using Ktor — Part 1
In the world of backend development, developers often face a choice: the heavy, feature-rich ecosystems like Spring Boot, or the…
The Kotlin-First Backend: Creating Scalable APIs using Ktor — Part 1

In the world of backend development, developers often face a choice: the heavy, feature-rich ecosystems like Spring Boot, or the ultra-minimalist frameworks that require manual labor for every feature. Enter Ktor. Ktor is JetBrains’ answer to modern, asynchronous backend development. Built from the ground up using Kotlin Coroutines, it is lightweight, flexible, and — most importantly — fun to use. Unlike other frameworks that force a specific way of doing things, Ktor allows you to plugin only the features you need. This makes it perfect for everything from tiny microservices to large-scale, enterprise-ready APIs. In this series, we aren’t just going to build a “Hello World” app. We are going to build a production-ready User Management API using a modern stack: Koin for Dependency Injection, Room (Kotlin’s favorite ORM) for persistence, and Ktor’s Plugin System for clean, modular code.
What You’ll Learn
By the end of this, you will have a fully functional server shell capable of handling requests, managing dependencies, and providing standardized error responses. Specifically, we will cover, the anatomy of a Ktor Server, the power of Ktor Plugins , DI with Koin, JSON Serialization, Global Exception Handling, Modular Routing.
Prerequisites
- Android Studio or IntelliJ IDEA (Ultimate or Community).
- JDK 11 or higher installed.
- A basic understanding of Kotlin (specifically Coroutines and Extension Functions).
The Architecture Overview
Our project follows a clean, modular structure. In this part, we will focus on the Application Core and Infrastructure:
- Main Entry Point: The heartbeat of our server.
- DI Layer: Where Koin resides.
- Plugins: The middleware that processes every request/response.
- Models: Defining our standard
ApiResponsewrapper to keep our frontend developers happy.
Setup: Project Structure and Gradle Configurations
To build a robust backend, you need a solid foundation. Ktor uses Gradle as its primary build system. In this project, we utilize the Kotlin DSL (build.gradle.kts) and Version Catalogs (libs.versions.toml) for a clean, modern dependency management experience.
The Ktor Project Generator Create new project using Ktor Project Generator, fill in the following to match your project’s identity:
- Project Name: KtorServer
- Build System: Gradle Kotlin DSL
- Engine: Netty
- Configuration: YAML
- Plugins: Add CORS, Call Logging, Content Negotiation, Koin, Resources, and Status Pages.
Click “Generate Project”. This will download a .zip file. This ensures all Ktor versions are compatible and sets up the standard directory structure automatically, saving you from “Dependency Hell.”
The Ktor Generator does not have a built-in option for Room Database. You must explain that once the project is generated, you need to manually add the Room and KSP dependencies to the build.gradle.kts file.
plugins {
// Generated by Ktor
alias(libs.plugins.kotlin.jvm)
alias(ktorLibs.plugins.ktor)
alias(libs.plugins.kotlin.serialization)
// MANUALLY added for the database layer
alias(libs.plugins.ksp)
alias(libs.plugins.room)
}
application {
mainClass = "io.ktor.server.netty.EngineMain"
}
room {
schemaDirectory("$projectDir/schemas")
}
dependencies {
// Ktor Server Core & Netty Engine
implementation(ktorLibs.server.core)
implementation(ktorLibs.server.netty)
// Serialization & Negotiation
implementation(ktorLibs.serialization.kotlinx.json)
implementation(ktorLibs.server.contentNegotiation)
// Utilities: CORS, StatusPages
implementation(ktorLibs.server.cors)
implementation(ktorLibs.server.statusPages)
// Dependency Injection
implementation(libs.bundles.koin)
// Database: Room
implementation(libs.room.runtime)
ksp(libs.room.compiler)
implementation(libs.sqlite.bundled)
}
Project Organization Structure is everything when an API starts to grow. Here is how we’ve organized our source code to keep it maintainable:
- com.zoro (Root): Contains
Main.ktand configuration files likeRouting.kt,Koin.kt, andPlugins.kt. - com.zoro.dao: Interfaces for Data Access Objects (Room).
- com.zoro.entity: Database entities representing our tables.
- com.zoro.model: Data Transfer Objects (DTOs) and common wrappers like ApiResponse.
- com.zoro.routes: Logic-specific route files (
UserRoutes.kt) to keep the main routing file clean. - com.zoro.di: Koin modules for providing database and repository instances.
The Entry Point We use the embeddedServer approach with the Netty engine. This gives us full programmatic control over the server lifecycle:
fun main() {
embeddedServer(
factory = Netty,
port = 8050,
host = "0.0.0.0"
) {
// Entry point for our plugin configurations
configurePlugins()
configureRouting()
}.start(wait = true)
}
Koin implementation for Clean Architecture
As your backend grows, manually passing database instances or configuration objects into your routes becomes a nightmare. This is where Dependency Injection (DI) comes in. Koin is the most natural choice — it’s lightweight, doesn’t use code generation (unlike Dagger/Hilt), and has first-class support for Ktor.
Defining Modules
In Koin, we define how our dependencies are created inside a module. For this project, we created a databaseModule that provides a singleton instance of our Room Database and the necessary DAOs.
// com.zoro.di.KoinModule.kt
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.zoro.db.AppDatabase
import org.koin.dsl.module
import java.io.File
val databaseModule = module {
// Provide the Room Database instance
single<AppDatabase> {
Room.databaseBuilder<AppDatabase>(
name = File("ktor_server.db").absolutePath
).setDriver(BundledSQLiteDriver())
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.build()
}
// Provide DAOs for injection into Repositories or Routes
single { get<AppDatabase>().userDao() }
single { get<AppDatabase>().productDao() }
}
Installing Koin in Ktor
Ktor makes it incredibly easy to attach Koin to your application lifecycle via its plugin system. We create an extension function on Application to keep things organized.
// com.zoro.Koin.kt
import com.zoro.di.databaseModule
import io.ktor.server.application.Application
import io.ktor.server.application.install
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun Application.configureKoin() {
install(Koin) {
// Log Koin events using SLF4J
slf4jLogger()
// Load the modules we defined
modules(databaseModule)
}
}
By using Koin, your route handlers or services don’t need to know how to create a database or a DAO — they just ask for it. This makes your code:
- Testable: We can easily swap the real database for a mock version during testing.
- Decoupled: Our business logic is separated from your infrastructure setup.
- Clean: No more “object drilling” (passing variables through five layers of functions).
Handling Cross-Origin Resource Sharing (CORS)
If you’re planning to connect an Android Emulator or a web app to this API, you’ll hit a wall without CORS. We’ve included a configureHttp plugin to allow the server to accept requests from different origins:
// com.zoro.Http.kt
import io.ktor.server.application.*
import io.ktor.http.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.response.*
fun Application.configureHttp() {
install(CORS) {
anyHost()
allowHeader(HttpHeaders.ContentType)
allowHeader(HttpHeaders.Authorization)
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Patch)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Options)
}
}
Routing and Content Negotiation Making the Server Speak JSON
Now that our infrastructure is set up with Koin, we need to allow the server to communicate with the outside world. By default, Ktor doesn’t know how to handle JSON — it see every request as a raw stream of bytes. To fix this, we use the Content Negotiation plugin.
Configuring Content Negotiation Ktor supports various serialization libraries, but Kotlinx Serialization is the preferred choice for Kotlin projects because of its speed and safety.
// com.zoro.ContentNegotiation.kt
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import kotlinx.serialization.json.Json
fun Application.configureContentNegotiation() {
// JSON — pretty printed, flexible parsing
install(ContentNegotiation) {
json(Json {
prettyPrint = true // Formats JSON for human readability
isLenient = true // Accepts quoted keys and relaxed syntax
ignoreUnknownKeys = true // Won't crash if the frontend sends extra fields
})
}
}
Modular Routing
In a real-world app, you don’t want all your endpoints in one file. We use extension functions on Route to group related logic into separate files like ProductRoutes.kt or UserRoutes.kt.
Notice how we use Koin’s inject() inside the route to get our database access object (DAO):
// com.zoro.routes.UserRoutes.kt
import com.zoro.dao.UserDao
import com.zoro.model.ApiResponse
import com.zoro.model.CreateUserRequest
import com.zoro.model.PatchUserRequest
import com.zoro.entity.User
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.*
import org.koin.ktor.ext.inject
fun Route.userRoutes() {
val userDao by inject<UserDao>()
route("/users") {
// GET /users
// Returns all users in the database
get {
val users = userDao.getAllUsers()
call.respond(
HttpStatusCode.OK,
ApiResponse(
success = true,
count = users.size,
data = users
)
)
}
// GET /users/{id}
// Returns single user by ID
get("{id}") {
val id = call.parameters["id"]?.toIntOrNull() ?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse<User>(
success = false,
message = "ID must be a number"
)
)
val user = userDao.getUserById(id) ?: return@get call.respond(
HttpStatusCode.NotFound,
ApiResponse<User>(
success = false,
message = "User with id $id does not exist"
)
)
call.respond(
HttpStatusCode.OK,
ApiResponse(success = true, data = user)
)
}
// ... fetch and respond
}
}
Standardizing Responses Consistency is key for any API. Instead of returning raw data, we wrap everything in a generic ApiResponse class. This ensures that every response — whether it’s a list of users or an error message — has the same structure.
// com.zoro.model.ApiResponse.kt
import kotlinx.serialization.Serializable
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val message: String? = null,
val data: T? = null,
val count: Int? = null
)
With this setup, your frontend team will love you. They only have to write one parser to handle every success and failure from your backend.
Status Pages: Handling Global Exceptions Early On
One of the worst things an API can do is return a raw “500 Internal Server Error” stack trace to the client. It’s unhelpful for the user and a security risk for you. Ktor’s Status Pages plugin allows you to intercept exceptions and status codes globally, ensuring your server always responds with a clean, structured JSON format.
The Global Error Handler
We install StatusPages to catch any Throwable (crash) that occurs during a request. Instead of letting the server crash or return a generic error page, we log the error and return our standard ApiResponse.
// com.zoro.StatusPages.kt
import com.zoro.model.ApiResponse
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
fun Application.configureStatusPages() {
install(StatusPages) {
exception<Throwable> { call, cause ->
println("ERROR: ${cause.message}")
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse<Unit>(
success = false,
message = cause.message ?: "Something went wrong on the server"
)
)
}
status(HttpStatusCode.NotFound) { call, _ ->
call.respond(
HttpStatusCode.NotFound,
ApiResponse<Unit>(
success = false,
message = "This route does not exist"
)
)
}
}
}
- Predictability: No matter what happens — a null pointer exception, a database connection failure, or a missing route — the frontend always receives a JSON object with success: false.
- Clean Code: You don’t need to wrap every single route in a try-catch block. You can write your business logic assuming things work, and let the global handler catch the edge cases.
- Security: You can choose exactly how much information to reveal in the message field, preventing internal system details from leaking.
Conclusion
We’ve successfully laid the groundwork for a professional Kotlin backend! By now, you have:
- A modular Project Structure.
- Koin managing your dependencies.
- Content Negotiation handling JSON automatically.
- A Global Error Handler keeping your API robust.
Your server is now a clean “shell” ready for business logic. In Part 2, we will dive into the Data Layer. We’ll see how to integrate Room Database into a Ktor environment (a rare but powerful combination!), handle file uploads for user profile pictures, and build out the full CRUD logic for our User and Product entities.
메타데이터
- post_id
- eaa6d3f25c38
- slug
- the-kotlin-first-backend-creating-scalable-apis-using-ktor-part-1-eaa6d3f25c38
- url
- https://medium.com/@kabi20/the-kotlin-first-backend-creating-scalable-apis-using-ktor-part-1-eaa6d3f25c38
- canonical_url
- https://medium.com/@kabi20/the-kotlin-first-backend-creating-scalable-apis-using-ktor-part-1-eaa6d3f25c38
- author_url
- https://medium.com/@kabi20
- status
- ok
- fetched_at
- 2026-06-18 00:10:23