Kandra

Kandra 0.4.6

A Kotlin ORM that doesn't pretend Cassandra is SQL with extra steps.

A Ktor-native ORM for ScyllaDB and Cassandra. Partitioning, denormalization, and distributed tradeoffs are visible in your entity definitions — so the query that looks correct actually is.

  • Kotlin 2.1+
  • ScyllaDB
  • Cassandra
  • Ktor plugin
  • Maven Central
  • Apache 2.0
User.kt + Application.kt
@ScyllaTable("users")
data class User(
    @PartitionKey val userId: UUID,
    @LookupIndex(tableSuffix = "by_email", consistency = LookupConsistency.BATCH)
    val email: String,
    val name: String,
)
 
install(Kandra) {
    contactPoints = "scylla:9042"
    keyspace = "app"
    register(User::class)
}
 
// Atomic write: users + the users_by_email lookup table, one LOGGED BATCH
userRepo.save(User(id, "dev@coinx.co.ke", "Pasaka"))

ISS-025

One call site, two eras.

Not a hypothetical: a real data-loss bug caught in Kandra's own development, and the fix that turned a silent partition wipe into a loud, named, call-time exception. Read the writeup ›

Todo.kt — today, v0.4.6
// Todo(ownerId: PartitionKey, todoId: ClusteringKey, ...)
 
todoRepo.deleteById(ownerId, todoId) // deletes exactly one row
 
todoRepo.deleteById(ownerId)
// Fails at call time: KandraSchemaException, naming the missing
// clustering-key column — not a silent partition wipe.

What building on Cassandra feels like without this.

Every one of these is a real failure mode, and several are root-caused bugs from Kandra's own development against a real cluster. A partition-oriented, leaderless database needs distributed thinking — that starts with naming the pain plainly. Read the full argument ›

Rack-aware replication, RF=3

One availability zone goes dark. Nothing pages.

Three replicas, one per zone. Writes go to all three; a LOCAL_QUORUM read only needs two to agree — the consistency model from Foundations. Tap a zone to take it offline.

2 of 3 replicas reachable. LOCAL_QUORUM reads and writes still succeed.

Running more than one datacenter? Multi-DC recipe ›

For teams evaluating an ORM

The Cassandra mistakes that page your team at 3am, caught before they ship.

Partition-wide deletes, batches that look atomic and aren't, tombstone buildup that degrades reads for days. Every card below is backed by a real, linked incident or a real annotation.

Prevent costly incidents

A relational-instinct delete or a batch that only looks atomic are exactly the kind of mistake that ships clean, passes review, and pages someone in production. Both are real, root-caused bugs from Kandra's own development, not hypotheticals.

Full-key requirements on deleteById/findById (fails loudly with KandraSchemaException, not silently) and saveInBatch's distinct method name (so it can't be shadowed into a non-atomic call) are both direct fixes for issues found against a real cluster.

Onboard engineers faster

Cassandra's distributed model is a steep learning curve for anyone coming from a relational background, and that knowledge usually lives in one senior engineer's head. Kandra puts it in the entity definition instead.

@PartitionKey, @ClusteringKey, and @LookupIndex make partitioning and denormalization explicit, typed decisions on the data class itself — not tribal knowledge a new hire has to be told about before their first write path.

Reduce operational overhead

Tombstone buildup and unbudgeted LWT cost are the kind of gradual, hard-to-diagnose problems that eat on-call time without ever being one clean incident to point at.

@SoftDelete uses a TTL instead of a DELETE and warns before a bulk delete would generate real tombstone volume. @Version/saveIfNotExists (LWT) stay opt-in and visible in the entity, never a hidden default you're paying for on every write.

For developers

An API that respects the database.

Three files, one running app: an entity that states its partitioning and lifecycle up front, a repository call site where atomicity is a named method, and a plugin install where schema and connection settings live in one place.

entities.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,
)
 
@ScyllaTable("todos")
@SoftDelete(ttlSeconds = 604_800, markerProperty = "isDeleted") // 7 days
data class Todo(
    @PartitionKey val ownerId: UUID,
    @ClusteringKey(order = ClusteringOrder.DESC) val createdAt: Instant,
    val todoId: UUID,
    val title: String,
    val isDeleted: Boolean = false,
)

Choose the tool that matches the database.

If you want SQL magic, use Spring Data. If you want raw control, use the driver directly. If you want Cassandra-native Kotlin, use Kandra.

DataStax driver Spring Data CassandraKandra
PartitioningManual PreparedStatement + partition-key discipline, on youHidden behind @IdExplicit @PartitionKey + @ClusteringKey
Dangerous queriesALLOW FILTERING is a plain query flag — no guardrailALLOW FILTERING is one flag awayStructurally absent from the predicate DSL — one logged exception (findActive())
Cross-table writesBatchStatement is available; atomicity discipline is on youManual, non-atomic by default@LookupIndex + logged batches, atomic by construction
How it wires upManual CqlSession lifecycle, no DIClasspath scanning, dynamic proxiesKtor plugin install, DI-native, traceable

Start small. Scale with confidence.

  1. Step 1

    Evaluate

    Read /foundations and /battle-scars before writing code — the distributed-systems tradeoffs and the real bugs they came from are the actual pitch, not a features list. Drop kandra-runtime + kandra-ktor into one service; nothing else has to change.

  2. Step 2

    Pilot

    Model one bounded context's tables with real @PartitionKey/@ClusteringKey/@LookupIndex decisions. Schema and query-shape validation at install time means a bad model fails at startup in a pilot, not under load in production.

  3. Step 3

    Scale

    The annotations that caught mistakes in the pilot are the same ones in production — nothing changes shape between the two. Standardizing means one distributed-data vocabulary across every Kotlin service, not one team's tribal knowledge.

Battle-tested, not theoretical.

Every entry is a real bug found in Kandra's own codebase, with a root cause and a fix — none of it deleted once it became inconvenient. See all battle scars ›

kandraSCYLLADBCASSANDRA

Add it to one service. Nothing else changes.

$ ./gradlew dependencies | grep kandra
+--- ke.co.coinx.kandra:kandra-runtime:0.4.6

FAQ·Report an issue