Contents

Learning Ktor Through a Spring Boot Lens. Part 1

Learning Ktor Through a Spring Boot Lens. Part 1 webp image

As a developer transitioning from Java to Kotlin, you might explore Kotlin-native frameworks. While it’s perfectly fine to use existing Java tools with Kotlin, thanks to its great interoperability, you might also want to try something different that fully benefits from Kotlin’s features.

When it comes to web application development, you have a choice: stick with the battle-tested Spring Boot, which integrates seamlessly with Kotlin, or dive into Ktor, a framework natively built for Kotlin’s unique features and idioms.

Both frameworks aim to simplify web development, but their philosophies and approaches are different in some aspects. Spring Boot provides a robust, opinionated toolkit designed for applications of any scale, while Ktor offers a lightweight, flexible framework tailored for Kotlin and enhancing developers' experience.

This article is for developers familiar with Spring Boot who want to dive into Ktor by leveraging their existing knowledge. By comparing key concepts such as application setup, routing, and dependency injection, we’ll uncover how Ktor works under the hood and how its design aligns with Kotlin’s idioms. Along the way, we’ll also discuss the differences and similarities, helping you get up to speed quickly without feeling overwhelmed.

Note: Due to the breadth of this topic, I have decided to divide the article into two parts. The first part will cover a detailed discussion of Application Setup, Server Engines, Configuration, and Dependency Injection. The second part will cover HTTP Request Handling and exception Handling, HTTP Clients, and Testing.

If you want to learn Ktor through the lens of what you already know, this article is for you. Let’s dive in and start exploring how Ktor brings Kotlin’s functional style into web development.

Setting up an application

One of the basic tasks that developers challenge while creating web apps is how to create the project and set the application up to run with the required configuration. This includes starting the underlying servlet engine, configuring dependencies or defining application modules. Both Spring Boot and Ktor provide ways to get up and running quickly, but their approaches differ significantly.

Spring boot

Spring Boot follows a "convention over configuration" philosophy, minimizing the boilerplate needed to get an application running. A typical Spring Boot application starts with an entry point class annotated with @SpringBootApplication:

@SpringBootApplication 
class MyApp 

fun main(args: Array<String>) { 
    runApplication<MyApp>(*args) 
}

This single annotation enables the following features:

  • Marks the class as a source of bean definitions
  • Enables Spring Boot's auto-configuration mechanism
  • Scans for components in the application's package hierarchy

Spring Boot's magic happens through its auto-configuration, which automatically sets up beans based on the dependencies in your classpath. Thus, usually, it doesn't require any implicit configuration; it just requires suitable dependencies to be added to the project.

To bootstrap your new project with suitable dependencies, you can use a dedicated generator web application that simplifies this task: start.spring.io. It creates a ready-to-use project based on configured target Spring Boot versions, the language you want to use, build tools and dependencies.

Ktor

In contrast, Ktor embraces a more explicit, modular approach using Kotlin's DSL capabilities. A basic Ktor application looks like this:

fun main() { 
  embeddedServer(Netty, port = 8080) { 
    routing { 
      get("/hello") { 
        call.respondText("Hello, World!") 
      } 
    } 
  }.start(wait = true) 
}

Ktor follows a "no magic" philosophy, making the application flow more transparent but requiring more explicit configuration, which involves Kotlin’s functional features with type-safe builders. Everything is opt-in and requires explicit declaration when needed. It doesn’t use annotations or reflection.

If you need to add functionality to your application, such as creating routes and exposing endpoints, enabling dependency injection, or using an HTTP client, you typically need to add the appropriate dependencies and install the corresponding plugin. A plugin is a modular component that extends the functionality of Ktor’s server and client. For example, to enable routing in your application, you would add the io.ktor:ktor-server-core dependency and install the Routing plugin.

Like Spring Boot, Ktor provides an online application generator: start.ktor.io, which simplifies the task of selecting suitable dependencies and plugins. Apart from basic information (target Ktor version, build system, engine, etc.), you must choose the plugins that you want to use in your application.

New%20Ktor%20Project

Server engines

When building web applications, the server engine forms the foundation of the application's runtime environment. Spring Boot and Ktor take different approaches to configuring server engines. While Spring Boot abstracts away much of the server implementation details, Ktor requires developers to make explicit choices about which engine powers their application.

Spring Boot

Spring Boot comes with Tomcat as its default embedded server but easily switches to Jetty or Undertow without changing the application code. The only change to use Jetty, for example, is to use a suitable dependency.

dependencies { 
  implementation("org.springframework.boot:spring-boot-starter-web") 

  // To switch to Jetty    
  implementation("org.springframework.boot:spring-boot-starter-jetty")    
  exclude("org.springframework.boot", "spring-boot-starter-tomcat") 
}

Server engine configuration is handled through properties in your application.properties or application.yml file. The framework automatically configures the embedded server based on these properties, allowing you to customize aspects like port numbers, thread pools, and SSL without touching any code.

Ktor

Ktor provides several server engines, including Tomcat, Netty, and Jetty. It also provides a server engine called CIO (Coroutine I/O). It is Ktor's own Kotlin-specific engine that's built from the ground up to leverage coroutines for highly efficient, non-blocking I/O operations, making it particularly well-suited for applications designed around Kotlin's concurrency model.

You need to add a suitable dependency to run an application on a specific server engine.

dependencies {
    implementation("io.ktor:ktor-server-core:$ktor_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version") // Using Netty

    // For other engines:
    // implementation("io.ktor:ktor-server-jetty:$ktor_version")
    // implementation("io.ktor:ktor-server-tomcat:$ktor_version")
    // implementation("io.ktor:ktor-server-cio:$ktor_version")
}

Ktor provides two ways to configure and launch the server. With embeddedServer, you programmatically configure and start your server engine in code, giving you fine-grained control over its setup and allowing for dynamic configuration.

fun main() {
    embeddedServer(Netty, port = 8080) {
        routing {
            get("/hello") {
                call.respondText("Hello, world!")
            }
       }
    }.start(wait = true)
}

Depending on the underlying server engine, you may provide a specific configuration. Example for Netty server:

fun main(args: Array<String>) {
    embeddedServer(Netty, configure = {
        shutdownGracePeriod = 1000
        shutdownTimeout = 2000
    }) {
        routing {
            get("/hello") {
                call.respondText("Hello, world!")
            }
        }
    }.start(wait = true)
}

In contrast, EngineMain takes a more declarative approach, where the server is configured via an external application.conf (or yaml) file and starts using a main function provided by Ktor, creating a clean separation between code and configuration instrumental in environments with changing deployment parameters.

fun main(args: Array<String>): Unit = EngineMain.main(args)

fun Application.module() {
    routing {
        get("/hello") {
            call.respondText("Hello, world!")
        }
    }
}

In this setup, engine configuration is stored in a dedicated file - application.conf (or yaml):

ktor {
    deployment {
        port = 8080
      shutdownGracePeriod = 2000
        shutdownTimeout = 3000
    }
    application {
        modules = [ pl.rmaciak.ApplicationKt.module ]
    }
}

Application configuration

Every application we build requires configuration to be customized based on the environment that it runs or specific tenant’s requirements. Spring Boot and Ktor offer different approaches to managing configuration including environment-specific settings. Let's explore how these frameworks handle application configuration.

Configuration files

Spring Boot

Spring Boot relies primarily on two types of configuration files: application.yml and application.properties. All the application configurations can be stored there, including custom properties.

To access specific properties in the code you can use @Value annotation.

@Component
class DatabaseProperties {
    @Value("\${spring.datasource.url}")
    lateinit var url: String

    @Value("\${spring.datasource.username}")
    lateinit var username: String
}

Another common way of accessing application configuration is using @ConfigurationProperties, which injects values from configuration to the class fields.

@ConfigurationProperties(prefix = "spring.datasource")
class DataSourceProperties {
    var url: String = ""
    var username: String = ""
}

Ktor

Ktor uses HOCON (Human-Optimized Config Object Notation) format, which offers more flexibility and features like the possibility to include another file, substitutions, or multi-line strings. Here’s a simple HOCON configuration file.

ktor {
    deployment {
        port = 8080
    }

    application {
        modules = [ pl.rmaciak.ApplicationKt.module ]
    }
}

database {
    url = "jdbc:postgresql://localhost:5432/myapp"
    username = "postgres"
}

logging {
    level = "INFO"
}

The configuration in Ktor is loaded from the application.conf (but you can also use Ktor with YAML configuration) in the resources directory from system properties or environment variables. You can access the configuration property using the environment field in your code.

fun Application.module() {
    val databaseUrl = environment.config.property("database.url").getString()
    val databaseUsername = environment.config.property("database.username").getString()
}

Ktor doesn’t have any built-in mechanism to inject configuration properties to the object fields (like with @ConfigurationProperties in Spring Boot), however it can be easily achieved with some piece of code.

data class DatabaseConfig(
    val url: String,
    val username: String,
)

fun Application.configureDatabases() {
    val config = environment.config("database")
    val databaseConfig = DatabaseConfig(
        url = config.property("url").getString(),
        username = config.property("username").getString(),
    )
}

Environment specific configuration

Spring Boot

To manage environment-specific configurations in Spring Boot, you typically use profiles that allow you to provide specific configurations per environment. You can do it by defining the application- .properties files with as the profile's name, which is usually called an environment name. For example, to separate DEV and PROD environments configuration, you have two files, application-dev.properties, and application-prod.properties, with a dedicated set of properties:

# application-dev.properties
server.port=8080
spring.datasource.url=jdbc:h2:mem:myapp

# application-prod.properties
server.port=80
spring.datasource.url=jdbc:postgresql://production-db:5432/myapp

To activate a specific profile you have to set spring.profiles.active property whether through command line argument or environment variable.

Ktor

Ktor doesn’t have any convention or a mechanism to work with environment-specific configuration; however, similarly to other aspects, you can easily achieve it with an explicit approach using HOCON's inheritance and substitution capabilities:

# application.conf

database { 
    url = "jdbc:h2:mem:devdb"
}

# For different environments, you can include environment-specific files
include "application-${?KTOR_ENV}.conf"
# application-prod.conf

database {
    url = "jdbc:postgresql://production-db:5432/proddb"
}

In this approach, the environment is defined in the application by the KTOR_ENV variable. If this is not set, there is a default configuration file being used (application.conf). For the production environment, the KTOR_ENV should be defined as "prod", so the application-prod.conf with suitable configuration for the production environment will be used.

Dependency injection

Dependency injection enables components to receive their dependencies (objects required to operate) rather than creating them, promoting loose coupling and testability. When transitioning from Spring Boot to Ktor, you may be surprised that Ktor doesn't provide an IoC container out of the box. It promotes explicitness through creating the components in the code directly, or you can use one of the plugins or frameworks that extend Ktor with DI capabilities.

Spring Boot

Spring Boot is built on Spring's Inversion of Control (IoC) container, which manages object creation and lifecycle. This system relies heavily on reflection and annotations. To define the class as a bean (an object that is managed by IoC and can be injected into another object) you can use following annotations - @Component, @Service, @Repository, @Controller, depending on the layer the class belongs to. There are a couple of injection ways; however, the recommended solution is to use constructor injection.

For example, to make UserService a bean and inject it into UserController, you should define it as a @Service and put it in the controller’s constructor. Spring automatically injects an instance of UserService to UserController.

@Service
class UserService(private val userRepository: UserRepository) {
    fun findUser(id: Long): User = userRepository.findById(id).orElseThrow()
}

@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {
    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): User = userService.findUser(id)
}

Ktor

As mentioned earlier, Ktor doesn’t include a built-in dependency injection solution. Instead, it encourages functional programming through function composition, DSLs, and on-demand object creation. One way to work with Ktor is to skip DI altogether and adopt an explicit, programmatic approach to defining dependencies. This works particularly well if your primary inversion-of-control need is to manage singletons. In Kotlin, you don’t need a framework for that. You can simply declare an object, which guarantees a single class instance.

object UserService {

    fun createUser() {
       //... some logic here
    }
}

However, this approach is limited as objects cannot have dependencies declared through the constructor. Thus, you usually need a DI framework anyway. Luckily, Ktor can be easily integrated with various DI frameworks:

  • Koin - pragmatic, lightweight DI framework written in pure Kotlin that uses functional resolution with no proxy or reflection.
  • Kodein - Kotlin-native dependency retrieval container that emphasizes type safety and readability while maintaining a small footprint, suitable for multiplatform projects.
  • Dagger - compile-time dependency injection framework that generates code to ensure type-safety, which offers excellent performance but with a steeper learning curve and more verbose configuration.
  • Guice - Google's mature Java-based DI framework that brings runtime flexibility and dynamic binding, though it's heavier than Kotlin-specific alternatives when used with Ktor.

In this article, I will explain how to use Koin with Ktor to enable dependency injection.

Using Koin with Ktor for dependency injection

To use Koin in your Ktor application, you need to add suitable dependencies and install Koin plugin:

build.gradle.kts

dependencies { 
    implementation("io.insert-koin:koin-ktor:$koin_version") 

    // Optional for logging with SLF4J
    implementation("io.insert-koin:koin-logger-slf4j:$koin_version")  
}
fun Application.main() {
    install(Koin) {
        slf4jLogger() // Optional for logging with SLF4J
        modules(appModule) // Enabling Koin module called appModule
    }
}

Koin uses the concept of a module, which is a container for defining Koin dependencies. Depending on your needs, all your beans should be declared within one or more modules.

Compared to Spring Boot, Koin offers fewer predefined bean scopes:

  • Single - creates a single instance for the entire container's lifetime (equivalent to a Singleton in Spring Boot)
  • Factory - creates a new instance each time it's requested
  • Scoped - creates an instance tied to the lifecycle of a user-defined scope

While Koin has fewer built-in scopes than Spring Boot, the scoped definition is highly flexible. It allows you to define custom scopes, making Koin more powerful and adaptable in certain use cases.

Here are some examples of defining beans in Koin:

val appModule = module {
    // 1. Singleton scope - one instance for the whole application, by deafault 
    lazy initialized 
    single<UserRepository> { UserRepositoryImpl() }

    // 2. Factory scope - creates a new instance each time it's requested
    factory { UserRepositoryImpl() }

    // 3. Multiple beans with named qualifiers
    single<PaymentGateway>(named("stripe")) { StripePaymentGateway() }
    single<PaymentGateway>(named("paypal")) { PaypalPaymentGateway() }

    // 4. Creating a bean with constructor parameters
    single { DatabaseConnection(get(), get<DatabaseConfig>().url) }

    // 5. Lazy singleton - only created when first accessed
    single(createdAtStart = false) { ExpensiveResourceManager() }

    // 6. Eager singleton - created at Koin startup
    single(createdAtStart = true) { SystemInitializer() }
}

To inject beans in your application you can use the following methods:

fun Application.configureRouting() {
    // 1. Property delegate injection
    val userRepository: UserRepository by inject()

    // 2. Direct retrieval from KoinComponent resolved by property type
    val userRepository: UserRepository = get()

    // 3. Direct retrieval from KoinComponent resolved by name
    val userRepository = get<UserRepository>(named("fakeUserRepository"))

    // 4. Lazy injection
    val lazyUserRepository: Lazy<UserRepository> by inject()

    // 5. Injecting with parameters
    val userRepository = get<UserRepository> { parametersOf("user123") }
}

Summary of the Part 1

The purpose of this part of the article was to provide an overview of the Ktor framework and its features, and to show how they relate to concepts familiar from Spring Boot, making it easier to learn. To summarize the aspects described and compared in the article:

Application Setup

  • Spring Boot: Uses annotations with auto-configuration and component scanning
  • Ktor: Adopts explicit, modular approach using Kotlin's DSL with no reflection or annotations

Server Engines

  • Spring Boot: Defaults to Tomcat, easily switches to Jetty/Undertow via dependencies
  • Ktor: Provides multiple engines including its own Coroutine I/O (CIO) engine with programmatic or declarative configuration

Configuration

  • Spring Boot: Uses YAML/properties files with annotation-based injection and profiles
  • Ktor: Uses YAML or HOCON format with direct property access and inheritance for environment-specific configs (HOCON only)

Dependency Injection

  • Spring Boot: Built-in IoC container with annotations
  • Ktor: No built-in DI; can use Kotlin objects or third-party solutions (Koin, Kodein, etc.)

As we wrap up this first part of the Learning Ktor Through a Spring Boot Lens article, I hope it has provided a solid foundation for understanding how Ktor’s core features align with familiar Spring Boot concepts. This exploration is a stepping stone toward embracing Kotlin-native solutions while leveraging prior knowledge. Stay tuned for the second installment, where I will dive into HTTP Request Handling & Exception Handling, HTTP Clients, and Testing - coming soon!

Reviewed by: Szymon Winiarz and Rafał Wokacz

Blog Comments powered by Disqus.