Kandra

Getting started

Five minutes: install, one entity, one query, against a real (local) ScyllaDB instance. This page intentionally skips the reasoning behind every decision — for that, go build the full app in /tutorial, which explains every line as it's written. If you haven't read /foundations yet, do that first; nothing below will feel arbitrary once you have.

Note

This page assumes a Ktor server project with Kotlin 2.1+, KSP configured, and Docker available for a local ScyllaDB instance. The full project scaffold, every dependency explained, is /tutorial/02-project-setup.

1. Run a local ScyllaDB

docker-compose.yml
services:
  scylla:
    image: scylladb/scylla:latest
    ports:
      - "9042:9042"
    command: --smp 1 --memory 750M --overprovisioned 1 --api-address 0.0.0.0
docker compose up -d

2. Add the dependencies

build.gradle.kts
dependencies {
    implementation(platform("ke.co.coinx.kandra:kandra-bom:0.4.6"))
    implementation("ke.co.coinx.kandra:kandra-ktor")
    implementation("ke.co.coinx.kandra:kandra-koin")
    ksp("ke.co.coinx.kandra:kandra-codegen")
}

The BOM aligns every Kandra module's version so you never repeat a version string; kandra-ktor is the plugin; kandra-koin auto-binds repositories into Koin; kandra-codegen (via KSP) generates type-safe *Table objects for the query DSL. See /tutorial/02-project-setup for why each of these, in depth, including the alternatives (kandra-kodein instead of kandra-koin).

3. Define an entity

User.kt
import io.kandra.core.annotations.*
import java.time.Instant
import java.util.UUID
 
@ScyllaTable("users")
data class User(
    @PartitionKey val userId: UUID,
    @LookupIndex(tableSuffix = "by_email", consistency = LookupConsistency.BATCH)
    val email: String,
    val name: String,
    @CreatedAt val createdAt: Instant = Instant.EPOCH,
)

@PartitionKey decides which node(s) this row lives on — see /foundations if "why this column" isn't obvious yet. @LookupIndex generates and maintains a second table, users_by_email, so "find by email" doesn't need a full scan.

4. Install the plugin

Application.kt
import io.kandra.ktor.*
 
fun Application.configureDatabase() {
    install(Kandra) {
        contactPoints = "localhost:9042"
        keyspace = "quickstart"
        localDatacenter = "datacenter1"
        autoCreateKeyspace = true
        schemaMode = SchemaMode.AUTO_CREATE
        register(User::class)
        auth { provider = KandraAuth.static("cassandra", "cassandra") } // local dev only, see below
    }
    kandraKoin()
}

schemaMode = SchemaMode.AUTO_CREATE runs CREATE TABLE IF NOT EXISTS for every registered entity at startup — right for a quickstart, not what you'd run in a production deploy pipeline (see /philosophy/schema-safety).

The auth block isn't optional, even for an unauthenticated local cluster

AuthConfig.provider defaults to KandraAuth.fromEnv(), and install(Kandra) calls it unconditionally while building the driver session — there's no "skip auth for local dev" default. Without the override above, this app throws KandraAuthException at startup unless SCYLLA_USERNAME/SCYLLA_PASSWORD are set in the environment, even though the docker-compose ScyllaDB above has no authentication enabled. KandraAuth.static(...) is fine for local dev; KandraAuth.fromEnv() (the default) is the right choice once there's a real cluster with real credentials — see /tutorial/04-plugin-install.

5. Save and query

routes.kt
import io.kandra.runtime.repository.KandraSuspendRepository
import org.koin.core.qualifier.named
import org.koin.ktor.ext.inject
 
fun Route.userRoutes() {
    val userRepo by inject<KandraSuspendRepository<*>>(named("UserSuspendRepo"))
 
    post("/users") {
        val user = call.receive<User>()
        userRepo.save(user)
        call.respond(HttpStatusCode.Created)
    }
 
    get("/users/by-email/{email}") {
        val email = call.parameters["email"]!!
        val user = userRepo.find { UserTable.email eq email }
        call.respond(user ?: HttpStatusCode.NotFound)
    }
}

UserTable is generated by kandra-codegen from the User entity — a compile-time-checked reference to the email column, not a magic string. userRepo.find { UserTable.email eq email } resolves through the users_by_email lookup table Kandra generated in step 3, not a full scan of users.

That's the whole loop: an annotated entity, a plugin install, a type-safe query. Nothing here explains why each piece is shaped this way — that's what the rest of the site is for.

Tip

Next: /tutorial builds a real multi-user app — a todo list — from data modeling through shipping, with every decision explained as it's made. It's deliberately more thorough than this page; that's the point.