This tutorial walks you through building your first Katalyst service from an empty Gradle project to a running HTTP endpoint backed by a database. By the end you will have a service, a repository, a table, and a route — all discovered and wired automatically — and you will understand the shape of every Katalyst application.
You will build a small bookmarks API: store a URL, list the stored URLs.
You need:
- JDK 21 or newer (
java -versionto check). - Gradle (the wrapper below handles the version).
- Familiarity with Kotlin and basic Ktor concepts (routes,
call.respond).
No database server is required — this tutorial uses an embedded H2 database.
Create a new directory and a Gradle build. This tutorial uses a single-module layout.
mkdir bookmarks && cd bookmarks
gradle init --type kotlin-application --dsl kotlin --package com.example --project-name bookmarksReplace the generated app/build.gradle.kts with this:
plugins {
kotlin("jvm") version "2.4.10"
id("io.ktor.plugin") version "3.5.2"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
application
}
repositories { mavenCentral() }
dependencies {
val katalyst = "1.0.0-beta02"
implementation(platform("io.github.darkryh.katalyst:katalyst-bom:$katalyst"))
implementation("io.github.darkryh.katalyst:katalyst-starter-web")
implementation("io.github.darkryh.katalyst:katalyst-starter-engine-netty")
implementation("io.github.darkryh.katalyst:katalyst-starter-persistence")
}
application {
mainClass.set("com.example.ApplicationKt")
}You declare three things: the BOM and the starters you need. The BOM
(platform(...)) pins every Katalyst artifact to one version, so the starter coordinates
below carry no version of their own. Each starter then brings its external dependencies
transitively — you never list Ktor, Exposed, HikariCP, or a JDBC driver yourself:
katalyst-starter-webpulls in Ktor (server, content negotiation, JSON serialization). It is engine-agnostic, so you also add exactly one engine starter —katalyst-starter-engine-nettyhere pulls in the Netty engine.katalyst-starter-persistencepulls in Exposed, HikariCP, and the H2 and PostgreSQL drivers.
Run ./gradlew build once to confirm the dependencies resolve. You should see
BUILD SUCCESSFUL.
Katalyst reads infrastructure settings from YAML. Create
app/src/main/resources/application.yaml:
ktor:
deployment:
host: 0.0.0.0
port: 8080
database:
url: jdbc:h2:mem:bookmarks;DB_CLOSE_DELAY=-1
username: sa
password: ""
driver: org.h2.DriverThe ${VAR:default} syntax is available for environment overrides; you will see it in the
configuration how-to. For now, plain values are fine.
Create app/src/main/kotlin/com/example/Application.kt:
package com.example
import io.github.darkryh.katalyst.config.yaml.enableYamlConfiguration
import io.github.darkryh.katalyst.di.katalystApplication
import io.github.darkryh.katalyst.koin.KoinBeanEngine
import io.github.darkryh.katalyst.ktor.engine.netty.NettyServer
fun main(args: Array<String>) = katalystApplication(args) {
engine(NettyServer)
beanEngine(KoinBeanEngine)
features {
enableYamlConfiguration() // installs the YAML source; must run before database { }
}
database { fromConfiguration() }
scanPackages("com.example")
schema { createMissing() } // create tables that don't exist yet
}schema { createMissing() } tells Katalyst to create any table it discovers but cannot
find in the database — convenient for local development. In production you use
validateOnStartup() and run migrations.
Create app/src/main/kotlin/com/example/Bookmark.kt:
package com.example
import io.github.darkryh.katalyst.core.persistence.Table
import io.github.darkryh.katalyst.core.persistence.mapping
import io.github.darkryh.katalyst.repositories.Identifiable
import org.jetbrains.exposed.v1.core.dao.id.LongIdTable
data class Bookmark(
override val id: Long? = null,
val url: String,
val createdAtMillis: Long
) : Identifiable<Long>
object BookmarksTable : LongIdTable("bookmarks"), Table<Long, Bookmark> {
val url = varchar("url", 2048)
val createdAtMillis = long("created_at_millis")
override val mapping = mapping<Long, Bookmark> {
generatedId(id, Bookmark::id)
field(url, Bookmark::url)
field(createdAtMillis, Bookmark::createdAtMillis)
construct {
Bookmark(
id = this[id],
url = this[url],
createdAtMillis = this[createdAtMillis]
)
}
}
}The entity is a plain data class that implements Identifiable<Long> — Table and
CrudRepository both require it, so the framework knows where the primary key lives. Keep id
nullable with a null default: it is null before the row is inserted, and save uses that to
decide between insert and update.
A table is an Exposed LongIdTable that also implements Table<Id, Entity>. The
mapping { … } block tells Katalyst how to read a row and how to write inserts and
updates — you never touch Exposed's UpdateBuilder directly.
Create app/src/main/kotlin/com/example/BookmarkRepository.kt:
package com.example
import io.github.darkryh.katalyst.repositories.CrudRepository
import org.jetbrains.exposed.v1.core.dao.id.LongIdTable
class BookmarkRepository : CrudRepository<Long, Bookmark> {
override val table: LongIdTable = BookmarksTable
}Implementing CrudRepository<Id, Entity> gives you save, findById, findAll,
deleteById, and more — for free. Because the class is under a scanned package, Katalyst
registers it; you never new it up.
Create app/src/main/kotlin/com/example/BookmarkService.kt:
package com.example
import io.github.darkryh.katalyst.core.component.Service
class BookmarkService(private val repository: BookmarkRepository) : Service {
suspend fun add(url: String): Bookmark = transactionManager.transaction {
repository.save(Bookmark(id = null, url = url, createdAtMillis = System.currentTimeMillis()))
}
suspend fun list(): List<Bookmark> = transactionManager.transaction {
repository.findAll()
}
}Implementing Service marks the class for discovery and gives you transactionManager,
which wraps every database operation in a transaction. The BookmarkRepository constructor
parameter is injected by type — no wiring code.
Create app/src/main/kotlin/com/example/BookmarkRoutes.kt:
package com.example
import io.github.darkryh.katalyst.ktor.builder.katalystRouting
import io.github.darkryh.katalyst.ktor.extension.ktInject
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import kotlinx.serialization.Serializable
@Serializable
data class AddBookmarkRequest(val url: String)
fun Route.bookmarkRoutes() = katalystRouting {
post("/bookmarks") {
val service = call.ktInject<BookmarkService>()
val request = call.receive<AddBookmarkRequest>()
call.respond(HttpStatusCode.Created, service.add(request.url))
}
get("/bookmarks") {
val service = call.ktInject<BookmarkService>()
call.respond(service.list())
}
}You also need JSON content negotiation installed. Add a middleware in
app/src/main/kotlin/com/example/JsonMiddleware.kt:
package com.example
import io.github.darkryh.katalyst.ktor.middleware.katalystMiddleware
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
fun Application.jsonMiddleware() = katalystMiddleware {
install(ContentNegotiation) { json() }
}katalystRouting, katalystMiddleware, and katalystWebSockets are the three Ktor entry
points Katalyst installs automatically once DI is ready. Inside a route, call.ktInject<T>()
resolves a dependency.
Katalyst calls these top-level functions for you, so your own code never references them — your
IDE may flag them as unused. Install the Katalyst IDE plugin and
the IDE recognizes them as framework entrypoints, so no @Suppress("unused") is needed.
./gradlew runYou should see the bootstrap log, ending with the server starting:
INFO Katalyst - Bootstrap complete
INFO Application - Responding at http://0.0.0.0:8080
In another terminal, add and list a bookmark:
curl -X POST http://localhost:8080/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://kotlinlang.org"}'{"id":1,"url":"https://kotlinlang.org","createdAtMillis":1718700000000}curl http://localhost:8080/bookmarks[{"id":1,"url":"https://kotlinlang.org","createdAtMillis":1718700000000}]It works. The table was created at startup, the repository and service were discovered and injected, and the route was registered — and you wrote no DI configuration.
You now have a complete Katalyst application and have seen its five moving parts:
- The bootstrap (
katalystApplication { … }) selecting an engine, DI adapter, config source, database, scanned package, and schema policy. - A table (
Table<Id, Entity>+mapping) and a repository (CrudRepository). - A service (
Service) with transactional methods. - A route (
katalystRouting) and a middleware (katalystMiddleware).
- Add an event when a bookmark is created → Publish and handle events.
- Run a periodic cleanup job → Schedule background jobs.
- Move to a real Postgres database and profiles → Configure with YAML.
- Write tests for this service → Test your application.
- Understand what happens during boot → Architecture & bootstrap lifecycle.