Open-source project
raharrison/kotlin-ktor-exposed-starter avatar
raharrison/kotlin-ktor-exposed-starter

kotlin-ktor-exposed-starter is a Kotlin REST service with database-backed change notifications

Starter RESTful service with websocket notifications using Kotlin, Ktor and Exposed with H2, HikariCP and FlyWay

589 stars75 forksKotlinLicense varies

At a glance

What is it?
This starter project wires Ktor, Exposed, H2, HikariCP and Flyway into a small REST API over a Widget table, and pushes every create, update and delete out over a WebSocket. It is a reference skeleton rather than a framework, so the value is in the wiring choices it demonstrates, particularly the separate thread pool for blocking JDBC.
Who is it for?
Adopt kotlin-ktor-exposed-starter if you are starting a Kotlin backend and want to see how an async web framework, a coroutine-friendly SQL DSL, connection pooling and schema migrations are meant to fit together, since the project shows the whole arrangement rather than a fragment of it.
Can I use it commercially?
Not without permission. GitHub finds no licence file in the repository, and without a licence all rights are reserved by default: you may read the code but not reuse it. Check the README, or ask the authors, before using it.
Is it still maintained?
Yes. The repository last received commits 5 days ago.
What is it written in?
Mainly Kotlin, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

What the starter actually contains: one Widget table and a handful of routes

The scope is deliberately small. The starter project creates a new in-memory H2 database with one table for Widget instances, and the whole API surface is five routes over that table. The README lists them: GET /widgets returns all widgets, GET /widgets/{id} returns one by integer id, POST /widgets adds a widget from a JSON body, PUT /widgets updates an existing widget by passing the id inside the JSON request, and DELETE /widgets/{id} removes one.

That is the point of a starter. There is no user model, no authentication, no pagination and no configuration layer. What you get instead is a working shape: a domain object, a data access layer, a service, and a set of route handlers wired to them, with the wiring visible rather than hidden behind a framework's conventions.

The JSON body of a create is deliberately tiny:

json
{
    "name": "new widget",
    "quantity": 64
}

And the response adds the generated id and a timestamp:

json
{
    "id": 3,
    "name": "new widget",
    "quantity": 64,
    "dateUpdated": 1519926898
}

The dateUpdated field being a plain epoch number rather than an ISO string is worth noticing, because it tells you the DTO is a data class with a long and that Kotlin Serialization is handling the conversion. The README lists Kotlin Serialization for JSON serialization and deserialization, with Jackson listed as a repository topic, so the project moved from one JSON library to another at some point and the current arrangement is kotlinx.serialization.

The primary language is Kotlin, the default branch is master, the repository carries no GitHub releases, and the licence is not stated in the repository metadata, which is the one thing you should settle before copying any of this into a product.

Why a separate thread pool exists: blocking JDBC inside a coroutine server

This is the most transferable thing in the project, and it is the part most starters of this kind get wrong. The README puts it directly: as Ktor is async and based on coroutines, standard blocking JDBC may cause performance issues when used directly on the main thread pool, as threads must be reused for other requests. Therefore, another dedicated thread pool is created for all database queries, alongside connection pooling with HikariCP.

Two separate concerns are being solved there, and they are worth keeping apart. Connection pooling is about not opening a new TCP connection and authenticating for every query, and HikariCP is the pool implementation. The dedicated thread pool is about not blocking the event loop that Ktor's Netty server runs on, because a JDBC call blocks a thread until the database responds.

The failure mode this prevents is specific. If a blocking query runs on the shared pool, the thread is tied up for the duration of the query and cannot serve other requests. Under a burst, threads get consumed by waiting on the database rather than by doing work, and the server appears to hang while its CPU is idle. The symptoms look like a slow database even when the database is fine.

The repository topics confirm the rest of the stack: kotlin-coroutines for the concurrency model, netty for the async web server, and h2-database. The README names Exposed as the Kotlin SQL framework, which sits between the coroutine layer and JDBC, and its DSL means the queries in the data access layer read as Kotlin rather than as SQL strings.

The pattern generalises past this project. Any coroutine-based server that calls a blocking driver needs this separation, and the cost is that you now have two pools to size: the coroutine pool that serves requests and the JDBC pool that serves queries, plus the dedicated executor that moves work between them.

WebSocket notifications: how a change reaches other clients

The second feature that lifts this above a plain CRUD starter is the notification channel. The README states that all updates to Widget instances, meaning creates, updates and deletes, are served as notifications through a WebSocket endpoint at WS /updates, returning Notification instances containing the change type, id and entity where applicable.

A notification looks like this:

json
{ 
    "type": "CREATE", 
    "id": 12, 
    "entity": { 
      "id": 12, 
      "name": "widget1", 
      "quantity": 5, 
      "dateUpdated": 1533583858169 
    }
}

The change type is the important field. A consumer of this stream cannot treat every message as the same thing, because a DELETE has no surviving entity to send, which is why the entity is described as applicable rather than always present. The type enumerates CREATE, UPDATE and DELETE and the id identifies the affected record.

The websocket listener will also log out any text messages sent by the client. That sounds like an incidental detail, and it is: the project is a backend service, not a chat system, so inbound text on the socket is logged for debugging rather than acted on. The README points to a separate blog post about testing websockets for tools to exercise the endpoint, which tells you there is no client UI in this repository.

The architectural question this raises is how the server fans a change out to every connected client. The README does not describe the mechanism, whether it is a shared session registry that the service layer writes into, a broadcast channel, or something else, and that absence matters if you intend to copy the design, because the difference between a per-connection write and a broadcast to a shared flow is the difference between a server that scales to a few connections and one that does not.

Running it: three steps, an in-memory database and a port

The getting started instructions are short enough to state exactly. You clone the repo, then in the root directory you execute `./gradlew run`, and by default the server starts on port 8080. There is no separate database to provision, no migration step to run by hand and no configuration file to fill in, because the H2 database is in-memory and created at startup.

That is the main practical appeal of the starter as a first run: the whole feedback loop is a clone and a Gradle command. The Gradle wrapper is checked in, with gradlew and gradlew.bat at the repository root, so the build does not depend on a Gradle version already being installed.

Migrations are handled by Flyway, which the README lists among the libraries used for database migrations. Flyway applies versioned SQL migrations at startup, so the schema for the Widget table is created by the migration files under src rather than by hand, and the same migrations apply to the in-memory database used in tests, which is what makes the test schema and the runtime schema the same schema.

Version numbers in the README are worth a note. The update line says the project was updated for Kotlin 2.4.20, Ktor 3.6.0 and Exposed 1.6.0, while the Ktor badge in the README header shows 3.5.2 and the Kotlin badge shows 2.4.20. The disagreement between the badge and the update note is a small sign that the badges are maintained separately from the dependency declarations, so check the actual versions in build.gradle.kts rather than trusting either.

For the testing side, the README describes unit testing of services with AssertJ, where the data access layer and business logic are tested by initialising an in-memory H2 database with Exposed using the same schema as the main app, so database queries are fully tested without any mocking. Integration testing of the endpoints uses Rest Assured against a fully running server, and the README explains the preference for starting a complete server over the special test engine Ktor provides, on the grounds that HTTP behaviour can be tested without relying on special internal mechanisms.

The Docker image, and a detail in it that will bite you

The repository carries a Dockerfile, and it is a two-stage build that separates the Gradle toolchain from the runtime image.

The build stage:

dockerfile
FROM gradle:9-jdk25 AS build

USER gradle
WORKDIR /app

COPY build.gradle settings.gradle ./
COPY src/ ./src

RUN gradle installDist --no-daemon

The runtime stage:

dockerfile
FROM eclipse-temurin:25-jre-noble

EXPOSE 8080

WORKDIR /app

COPY --from=build /app/build/install/kotlin-ktor-exposed-starter .

ENTRYPOINT ["./bin/kotlin-ktor-exposed-starter"]

The final image carries only a JRE, and the entrypoint is the installDist script rather than a jar invocation, so the distribution includes the start scripts that Gradle's application plugin generates.

There is a mismatch here worth flagging before you try to build it. The Dockerfile copies build.gradle and settings.gradle, but the repository's top-level entries include build.gradle.kts, and the Kotlin DSL and Groovy DSL files are different files. The copy step names a file that is not in the list of top-level entries, and it also does not copy gradle.properties or the gradle directory, so the wrapper and the version catalogue it might carry are not present either. If you build this image as-is and it fails, that mismatch is the first thing to look at.

The port choice is consistent with the running instructions. The image exposes 8080 and the README says the server starts on 8080 by default, so the two agree. What the image does not set is anything about the database: since H2 is in-memory, the container holds its state in memory and loses it when the container stops, which is the point for a sample but means this image is not a deployment strategy for an application with real data.

Where this starter stops being the right answer

Three limits, and the first is about what the project is for rather than about its quality.

The first is that it is a teaching artifact, not a base to build a product on. One table, five routes, no authentication, no authorisation, no input validation beyond what the DTO shape implies, no error handling strategy, no configuration beyond the port, and no released versions to pin. It also has no GitHub releases, so there is no version history to upgrade through and nothing to depend on by tag. Copying it means copying the wiring and writing the application yourself, which is fine, but the honest framing is that you are reading a sample rather than adopting a framework.

The second is the durability of the data layer. An in-memory H2 database with Flyway migrations is a good fit for tests and a demonstration, and a poor fit for anything that needs to survive a restart. If you keep the schema and replace the database, the part you keep is the Exposed DAO and the migration files, and the part you must write is everything about pooling configuration, backups and schema evolution across versions of the migration history.

The third is the version drift visible in the README itself, where the badge and the update note disagree about Ktor, and the Dockerfile's copy of build.gradle against a repository that ships build.gradle.kts. Ktor in particular has moved through major versions with breaking changes to the server configuration and the plugins, so a starter written against one generation is a starting point you will spend time adapting. The README's companion article dates from 2018, which means the article describes the original design rather than the current code.

The alternative approach is worth stating plainly: use the official Ktor start generator and the official Exposed templates, which give you a project with matching versions and current idioms, and take from this repository only the specific decisions that are hard to arrive at on your own, namely the dedicated JDBC thread pool, the integration test style of running a full server, and the notification payload shape. The difference in approach is that the official generators give you a maintained baseline that compiles today, while this repository gives you a coherent worked example whose wiring you can read in one sitting.

Editorial conclusion

Adopt kotlin-ktor-exposed-starter if you are starting a Kotlin backend and want to see how an async web framework, a coroutine-friendly SQL DSL, connection pooling and schema migrations are meant to fit together, since the project shows the whole arrangement rather than a fragment of it. Do not adopt it as a framework or as production scaffolding, because it is a single Widget table with no authentication, no configuration beyond the port, and no released versions, so you would be copying structure and nothing else. Verify first that the version numbers in the README agree with the ones in your build, because the badge and the update note disagree about Ktor, that Flyway migrations are applied to the schema the tests expect, and that the Docker image builds from a source tree that still contains build.gradle.kts, since the Dockerfile copies build.gradle and settings.gradle.

Frequently asked questions

How do I run the kotlin-ktor-exposed-starter project locally?

Clone the repo, then in the root directory execute ./gradlew run. By default the server starts on port 8080, and because H2 is used the in-memory database is created at startup, so there is nothing to provision first.

Which REST endpoints does the Kotlin starter expose?

GET /widgets returns all widgets, GET /widgets/{id} returns one by integer id, POST /widgets adds one from a JSON object, PUT /widgets updates an existing widget with the id passed in the JSON request, and DELETE /widgets/{id} removes one.

How do the WebSocket notifications work in kotlin-ktor-exposed-starter?

All creates, updates and deletes to Widget instances are served through the WS /updates endpoint as Notification instances containing the change type, id and the entity where applicable, for example a type of CREATE with the affected widget.

Why does the Kotlin starter use a separate thread pool for database queries?

Because Ktor is async and coroutine based, blocking JDBC on the main thread pool can cause performance problems since threads must be reused for other requests. The project therefore creates a dedicated thread pool for all database queries, alongside HikariCP connection pooling.

How is the project tested?

Services are unit tested with AssertJ against an in-memory H2 database initialised with Exposed using the same schema as the main app, so queries are tested without mocking. Endpoints are integration tested with Rest Assured against a fully running server, and coverage is produced by Kover as part of the Gradle build.

Can I build a Docker image for the Kotlin starter?

A Dockerfile is included, using a two-stage build with a gradle:9-jdk25 build stage running gradle installDist and an eclipse-temurin:25-jre-noble runtime stage exposing port 8080. Note that the Dockerfile copies build.gradle and settings.gradle while the repository root lists build.gradle.kts, so check that copy step before relying on the image.

Official sources

  1. Issues
  2. raharrison/kotlin-ktor-exposed-starter on GitHub
  3. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/raharrison-kotlin-ktor-exposed-starter.svg)](https://hysenlabs.com/projects/raharrison-kotlin-ktor-exposed-starter)

Was this useful?