I recently had to get up to speed with Kotlin and Ktor at work to contribute to a microservice. Coming from a Java Spring Boot and C# ASP.NET Core background, most of it felt immediately familiar — controllers, repositories, dependency injection, middleware. The MVC mental model transfers cleanly. But there were a few Kotlin-specific patterns that genuinely surprised me, and Ktor's plugin architecture is quite different from Spring's annotation-driven approach. Here's what I wish I'd had when I started.
Project Structure
The layered architecture is the same as Spring Boot — same layers, different idioms:
src/main/kotlin/com/example/├── Application.kt # Entry point & module wiring├── controllers/ # HTTP endpoint handlers├── repositories/ # Database access├── services/ # Business logic├── entities/ # Data class definitions├── enums/ # Domain enumerations├── database/ # HikariCP setup, DatabaseService interface├── plugins/ # Ktor plugins (routing, auth, DI)└── utils/ # Custom serializers
If you've worked in Spring Boot or ASP.NET Core, this should feel like home.
Data Classes and Companion Objects
Data classes are the first thing that stood out. In Spring Boot you'd typically reach for Lombok to generate equals(), hashCode(), toString(), and a copy()-style builder. In Kotlin, that's all part of the language — declare a data class and you get all of it for free. The @Serializable annotation from kotlinx.serialization is all Ktor needs to convert it to JSON — no Jackson config, no ObjectMapper bean.
// entities/Document.kt@Serializabledata class Document(val id: String,val title: String,val category: Category,val owner: String,@Serializable(with = InstantAsStringSerializer::class)val createdAt: Instant,val metadata: JsonObject = JsonObject(emptyMap()),)
The @Serializable(with = ...) lets you attach a custom serializer inline for types that don't serialize cleanly by default, like Instant.
The thing that was genuinely new to me was companion objects. In Java or C#, static factory methods live directly on the class. Kotlin doesn't have statics — instead, you define a companion object inside the class body, and its members are accessible directly on the class name:
data class Document(...) {companion object {fun from(row: DocumentRow): Document =Document(id = row.externalId,title = row.title,category = Category.fromString(row.categoryName),owner = row.ownerExternalId,createdAt = row.createdOn,)}}
You call it as Document.from(row) — the same ergonomics as a static factory in Java, just without the static keyword. Once I understood that companion objects are essentially the Kotlin equivalent of a class-level namespace, they started showing up everywhere.
Custom Serializers
When a type doesn't serialize the way you want, you implement a KSerializer. Here's one that formats Instant as a readable date string rather than an epoch number:
// utils/InstantAsStringSerializer.ktobject InstantAsStringSerializer : KSerializer<Instant> {override val descriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault())override fun serialize(encoder: Encoder, value: Instant) {encoder.encodeString(formatter.format(value))}override fun deserialize(decoder: Decoder): Instant =Instant.parse(decoder.decodeString())}
The object keyword means this is a singleton — the Kotlin equivalent of a static class with no constructor. You reference it with @Serializable(with = InstantAsStringSerializer::class) on any field that needs it.
Enums with Lazy Properties
This was another pattern I hadn't seen before. In C# or Java you'd typically map enum variants to config values in a switch statement somewhere. Kotlin lets you attach properties directly to enum variants using by lazy:
// enums/Category.kt@Serializableenum class Category {Reports,Invoices,Statements,Unknown,;val storageBucket: String by lazy {when (this) {Reports -> EnvConfig.getString("storage.reports.bucket")Invoices -> EnvConfig.getString("storage.invoices.bucket")Statements -> EnvConfig.getString("storage.statements.bucket")Unknown -> ""}}val pathPrefix: String by lazy {when (this) {Reports -> EnvConfig.getString("storage.reports.prefix")Invoices -> EnvConfig.getString("storage.invoices.prefix")Statements -> EnvConfig.getString("storage.statements.prefix")Unknown -> ""}}companion object {fun fromString(value: String?): Category = when (value?.lowercase()) {"reports" -> Reports"invoices" -> Invoices"statements" -> Statementselse -> Unknown}}}
The by lazy means the config lookup only runs once per enum variant, the first time that property is accessed. It's a memoized computed property. You can then call Category.Invoices.storageBucket anywhere without needing a lookup table or an injected service.
The Database Layer
Instead of a raw DataSource, I use HikariCP for connection pooling and wrap it behind a DatabaseService interface — similar to Spring's JdbcTemplate or C#'s IDbConnection abstraction:
// database/DatabaseService.ktinterface DatabaseService {fun getConnection(): Connectionfun <T> executePreparedQuery(query: String, vararg parameters: Any?, resultHandler: (ResultSet) -> T): Tfun executePreparedUpdate(query: String, vararg parameters: Any?): Int}class HikariDatabaseService(config: DatabaseConfig) : DatabaseService {private val dataSource: HikariDataSourceinit {val hikariConfig = HikariConfig().apply {driverClassName = "com.mysql.cj.jdbc.Driver"jdbcUrl = "jdbc:mysql://${config.host}:${config.port}/${config.name}"username = config.usernamepassword = config.passwordmaximumPoolSize = 10minimumIdle = 2addDataSourceProperty("cachePrepStmts", "true")addDataSourceProperty("rewriteBatchedStatements", "true")}dataSource = HikariDataSource(hikariConfig)}override fun <T> executePreparedQuery(query: String,vararg parameters: Any?,resultHandler: (ResultSet) -> T,): T = getConnection().use { conn ->conn.prepareStatement(query).use { stmt ->parameters.forEachIndexed { i, param -> stmt.setObject(i + 1, param) }stmt.executeQuery().use(resultHandler)}}}
The .use { } idiom is Kotlin's equivalent of try-with-resources in Java or using in C#. It calls close() on the resource when the block exits, even if an exception is thrown.
The Repository
Repositories get their DatabaseService via Kodein, a lightweight DI container. The DIAware interface is how classes declare their dependency on the container — similar to Spring's @Autowired but explicit:
// repositories/DocumentsRepository.ktclass DocumentsRepository(override val di: DI) : DIAware {private val databaseService: DatabaseService by di.instance()fun getDocumentsForOwner(ownerIds: List<String>): List<Document> {if (ownerIds.isEmpty()) return emptyList()val placeholders = ownerIds.joinToString(",") { "?" }val query = """SELECT d.external_id, d.title, c.name AS category_name,d.owner_external_id, d.created_onFROM documents dLEFT JOIN categories c ON c.id = d.category_idWHERE d.owner_external_id IN ($placeholders)ORDER BY d.created_on DESC""".trimIndent()return databaseService.executePreparedQuery(query, *ownerIds.toTypedArray()) { rs ->buildList {while (rs.next()) {add(Document.from(DocumentRow(externalId = rs.getString("external_id"),title = rs.getString("title"),categoryName = rs.getString("category_name"),ownerExternalId = rs.getString("owner_external_id"),createdOn = rs.getTimestamp("created_on").toInstant(),)))}}}}}
The *ownerIds.toTypedArray() is Kotlin's spread operator — it unpacks a list into varargs, the equivalent of passing params.toArray() in Java before calling a varargs method.
Routing: The Ktor DSL
This is where Ktor diverges most from Spring Boot. There are no annotations — routing is defined in code using a builder DSL:
// plugins/RoutingPlugin.ktfun configureApplicationRouting(app: Application, di: DI) {app.install(ContentNegotiation) { json() }app.routing {get("/ping") { call.respond("pong") }route("/v1") {authenticate("bearer-auth") {get("/documents") {val owners = call.request.queryParameters.getAll("owner") ?: emptyList()val docs = documentsController.listForOwners(owners)call.respond(HttpStatusCode.OK, docs)}get("/documents/{category}") {val category = Category.fromString(call.parameters["category"])val owners = call.request.queryParameters.getAll("owner") ?: emptyList()val docs = documentsController.listByCategory(owners, category)call.respond(HttpStatusCode.OK, docs)}get("/document/{id}") {val id = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.BadRequest)val doc = documentsController.getById(id)call.respond(if (doc != null) HttpStatusCode.OK else HttpStatusCode.NotFound,doc ?: "Not found",)}}}}}
The authenticate("bearer-auth") { } block wraps any routes that require a Bearer token. It reads a lot like Express middleware nesting, except it's typed and enforced at compile time.
Plugin-Based Application Configuration
Instead of Spring's @Configuration classes or ASP.NET's Startup.cs, Ktor uses plugins — installable modules that each handle a piece of the application setup. The order they're installed in is the order they run:
// Application.ktfun Application.module() {install(SecretsPlugin) // Load config from secrets managerinstall(DatabasePlugin) // Init HikariCP after secrets are availableinstall(DIPlugin) // Register repositories and controllersinstall(RoutingPlugin) // Wire up routes after DI is ready}
When initialization ordering gets more complex — say, the database plugin needs secrets before it can connect — you can enforce it with custom Ktor events:
// events/ApplicationEvents.ktobject SecretsLoaded : EventDefinition<Unit>()object DatabaseReady : EventDefinition<Unit>()object DIReady : EventDefinition<Unit>()
Each plugin raises the next event when it's done, and the next plugin listens for it before starting. You get an explicit, auditable startup sequence without the magic of Spring's @DependsOn.
Takeaway
Coming from Spring Boot and C#, most of the architecture is immediately legible — the controller/repository split, DI, connection pooling, and bearer auth all work the same way conceptually. The differences are in the idioms: companion objects instead of statics, by lazy for memoized enum properties, .use { } for resource cleanup, and a routing DSL instead of annotations. Once those patterns clicked, the rest of the Kotlin codebase started feeling genuinely pleasant to work in.
