A hands-on guide for Java developers exploring the Kotlin-native stack.
After seven years in Java, I joined a Kotlin project and got curious about the native ecosystem. To learn it, I built a small Trading Accountability App — a tool that logs trades, enforces trading rules, and flags violations when discipline slips — from scratch using only Kotlin-native libraries, and this article walks you through doing the same.
By the end, you'll have a running Ktor server with dependency injection (Koin), a type-safe database layer (Exposed), and REST endpoints with JSON serialization. Along the way, you'll see how to wire up dependency injection, manage database transactions explicitly, and handle errors globally — patterns you'll reuse in any Ktor project.
The Stack
Here's what we're working with, all at their latest stable versions as of March 2026:
- Ktor 3.4.1 — HTTP server framework by JetBrains. Lightweight, coroutine-native.
- Koin 4.1.1 — Dependency injection. No code generation, no reflection.
- Exposed 1.2.0 — SQL library by JetBrains. Type-safe database access.
- Kotlin 2.3.20, Gradle 9.4.1, JDK 25 (LTS)
You can scaffold a Ktor project at start.ktor.io, or just add the dependencies manually. Here's the full build.gradle.kts:
Notice the Ktor dependencies have no version. The io.ktor.plugin Gradle plugin applies the Ktor BOM, locking all Ktor artifacts to the same version so you don't have to repeat it. It also gives you ./gradlew run to start the server directly from the terminal.
With the dependencies in place, let's look at the entry point.
The Entry Point
When using Spring Boot, @SpringBootApplication scans your classpath and auto-configures components based on the dependencies it finds. Ktor takes the opposite approach. You start with a bare server and install each plugin explicitly. In our example the main function looks like this:
Each configure call is an extension function on Application that we write ourselves. For example, configureKoin() sets up the DI container, configureSerialization() sets up JSON handling, and so on. We'll walk through the key ones in the sections that follow.
Now, let's run it with ./gradlew run:
Four DI definitions loaded in under 2 milliseconds, server responding in 0.39 seconds. That's what you get when there's no classpath scanning, no proxy generation, and no auto-configuration at startup.
Here's the full project layout, for reference:
Dependency Injection with Koin
In Spring, you'd declare beans with @Component or @Bean and let the framework discover them. In Koin, you register them explicitly: single { } creates a singleton (the equivalent of Spring's default scope), and get() resolves a dependency by type. Koin also supports other scopes like factory { } for per-call instances. You group these definitions into module { } blocks, which act as containers you can load selectively:
Because everything is just Kotlin code, you can jump to the definition of any dependency and land directly in the constructor.
Here's an extension method configureKoin(), that we used previously during Ktor setup. We call install(Koin) to add it to the server, then configure it inside the block:
modules() tells Koin which definitions to register. This is where the modules we defined earlier get loaded into the container.
Connecting to the Database
Before we dive into Exposed's query DSL, let's look at how the database gets set up. Here's configureDatabase(), another extension function on Application, just like configureKoin():
Notice the suspend keyword. Since Ktor 3.2, module functions can be suspended, so they don't block a thread during initialization. With H2 in-memory, it doesn't matter much, but it pays off when your setup gets heavier (we'll see this in Part 2).
SchemaUtils.create() generates CREATE TABLE IF NOT EXISTS statements from your table definitions. Exposed works with any JDBC DataSource, so swapping H2 for PostgreSQL later is a one-line change in Database.connect().
suspendTransaction is Exposed's coroutine-compatible transaction function. It lets you call suspend functions inside the block. We'll cover how it interacts with dispatchers in the Transactions section below.
Defining Tables and Queries with Exposed
Exposed offers two approaches. DAO is entity-centric and will feel familiar if you've worked with JPA/Hibernate. DSL is SQL-first and type-safe: you write queries that map almost 1:1 to the SQL they generate. We're going with DSL in this tutorial, because the queries read close to raw SQL, which makes it easier to see what's happening at the database level. For a detailed comparison of both, check out Szymon Winiarz's Hibernate vs. Exposed series.
Table Definitions
Exposed provides convenience base classes like IntIdTable and LongIdTable that auto-generate the primary key column for you. Here we use the plain Table class to show the full structure explicitly:
Let's walk through the column modifiers you see in these definitions.
.autoIncrement() tells Exposed to skip the ID column in INSERT and let the database generate it. In our create() method, we never set id manually. Exposed knows to leave it out. It also affects schema generation, adding AUTO_INCREMENT (or SERIAL in PostgreSQL) to the column definition.
.index() creates a single-column database index when SchemaUtils.create() runs. If you later switch to Flyway or Liquibase, your migration scripts take over that responsibility, but it's still worth keeping .index() in the table definition as documentation of your schema.
.nullable() makes the column Column<BigDecimal?> in Kotlin, so the compiler won't let you forget to handle null. It also lets you skip that column in INSERT statements. In our code, exitPrice is nullable, and we don't set it when opening a trade. Exposed inserts null automatically.
.default("OPEN") works similarly. It tells Exposed that the database will fill in the value, so you can omit it in INSERT. In our code, we never set status when creating a trade. The database defaults it to "OPEN".
DTOs and Serialization
So we have our tables, but how do we actually send data back and forth? In Exposed, table definitions (object TradingRules, object Trades) only describe the schema. They're Kotlin singletons, not objects that represent values from the database. The data you receive from clients and return in responses lives in separate data class DTOs. For simplicity, we use the same classes for both database mapping and API responses:
We're using @Serializable from kotlinx.serialization to mark which classes can be converted to and from JSON. Remember the configureSerialization() plugin from fun main()? That installed ContentNegotiation, which picks up these annotations and handles the conversion automatically when you call call.respond() or call.receive(). Think of it as Ktor's equivalent of Jackson in Spring.
Heads up: kotlinx.serialization doesn't support BigDecimal out of the box. You need a small custom serializer that converts BigDecimal to/from String in JSON. It's ~10 lines, a KSerializer<BigDecimal> with PrimitiveSerialDescriptor. Without it, your first call.respond() will throw. The full implementation is in the GitHub repo.
Type-Safe Queries
Querying feels like writing SQL, except the compiler catches your mistakes:
toTrade() is an extension function on ResultRow that we write ourselves, mapping each column to the corresponding DTO field.
Try writing Trades.ticker eq 42 and it won't compile. ticker is a varchar, not an integer. In JPA, you'd write a JPQL string and discover the error at runtime.
Foreign keys work the same way. Define a reference in your table, and Exposed handles the rest:
Unlike JPA, there's no implicit fetching or lazy-loading happening behind the scenes. What you write is what gets executed.
Watch out: Exposed 1.0 (January 2026) reorganized every package under a v1 prefix. Most existing tutorials and examples still use the old 0.x imports. If you see org.jetbrains.exposed.sql.* somewhere online, the 1.x equivalent is org.jetbrains.exposed.v1.core.* or org.jetbrains.exposed.v1.jdbc.*. Check the migration guide for details.
Managing Transactions
In Spring, you put @Transactional on a service method, and a proxy handles everything invisibly. In Exposed, you wrap your business logic in an explicit block:
The pattern is identical to Spring: transactions are handled at the service layer, and repositories don't manage their own transactions. The difference is that you see exactly where the transaction boundary is.
It might look like an unnecessary boilerplate to manually handle transactions in the method. However, for more complicated code, when you want one service method to handle part of the code that needs to be inside the transaction, and another that needs to be outside the transaction, this comes in handy.
Notice that TradeService depends on TradeRepository, an interface. The actual DSL calls live in TradeRepositoryImpl. Services and routes never import anything from org.jetbrains.exposed, which means you can swap the implementation for a fake in tests.
Now, why the withContext(Dispatchers.IO) wrapper? Every route handler in Ktor is a suspend function, and Ktor's Netty engine runs them on its event loop threads. JDBC calls are blocking, so if we ran them directly on that event loop, we'd stall it for all other connections. suspendTransaction alone doesn't help here. It makes the block coroutine-compatible (you can call suspend functions inside it), but it doesn't switch threads. It runs on whatever dispatcher is already active. withContext(Dispatchers.IO) moves the work to a thread pool designed for blocking I/O.
In configureDatabase() earlier, we used suspendTransaction without withContext because that code runs during server startup, before Netty starts accepting requests. There's no event loop to block yet.
Exposing HTTP Endpoints
Now let's cover configureRouting() from our entry point. Here's where it leads to:
The actual HTTP endpoints are short:
Routes are plain functions inside a DSL block. by inject<TradeService>() is Koin's lazy delegate that resolves the dependency from the DI container on first access. To get a deserialized request body, we simply use call.receive(). To send the response, we use call.respond(), which serializes it automatically.
You may wonder, won't throwing an IllegalArgumentException crash the server? It won't, thanks to configureStatusPages(), the plugin we installed in fun main(). It catches it and returns a 400 Bad Request. It's Ktor's equivalent of Spring's @ControllerAdvice.
The Trade-offs: What You're Giving Up
Spring has a "Starter" for almost everything: SOAP, batch processing, mail sending, you name it.
In Ktor, you'll occasionally write integration code that Spring hands you for free. You'll also write more wiring code. Those explicit configure functions, those module { } blocks. Spring would have handled all of that with annotations. And the mental model is different: managing Exposed transactions or Koin scopes isn't hard, but it's not what your muscle memory expects after years of @Transactional and @Autowired.
For me, the trade-off is worth it. The application starts faster, has a smaller footprint, and you can read the code and know exactly what it does.
What's Next
We have a working backend, but there's plenty more to add before it's production-ready. In Part 2, we'll secure it with API Key authentication (Ktor 3.4 has a built-in provider for this), generate OpenAPI docs straight from our route definitions, clean up build.gradle.kts with a Version Catalog, and write both unit and integration tests using Ktor's testApplication DSL and fake repositories instead of mocks.
The full source code is on GitHub. The repo is tagged by article progression: v0 for a minimal runnable scaffold, v1 for the complete implementation from this article, and v2 will cover Part 2.
If you want a broader comparison of Ktor and Spring Boot beyond what we covered here, including server engines, HOCON configuration, HTTP clients, and type-safe routing, check out Rafał Maciak's excellent two-part series: Learning Ktor Through a Spring Boot Lens.




