v0.4.6 · Apache 2.0 · Kotlin + ScyllaDB/Cassandra
Kandra is a Ktor-native ORM for ScyllaDB and Cassandra. It makes partitioning, denormalization, and distributed tradeoffs visible in your entity definitions — so the query that looks correct actually is.
@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"))For teams evaluating an ORM
Partition-wide deletes, batches that look atomic and aren't, tombstone buildup that degrades reads for days — every one of these is a real, root-caused bug from Kandra's own development against a live cluster, not a theoretical pitch.
Every failure mode on this page is documented with a root cause and a fix — not hypothetical.
This isn't a hypothetical — it's ISS-025, a real data-loss bug caught in Kandra's own development, and the fix that replaced a silent partition wipe with a loud, named, call-time exception. Full writeup →
// 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.Every one of these is a real failure mode, not a hypothetical — several are documented, root-caused bugs from Kandra's own development, caught against a real cluster. This isn't Cassandra taught by analogy to SQL: a distributed, partition-oriented, leaderless database needs distributed thinking, and that starts with naming the pain plainly. Read the full argument →
Every card below is backed by a real, linked incident or a real annotation — insurance, not a features list.
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.
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.
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.
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 the schema and connection settings live in one place.
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,
)| Raw DataStax Driver | Spring Data Cassandra | Kandra | |
|---|---|---|---|
| Partitioning | Manual PreparedStatement + partition-key discipline, on you | Hidden behind @Id | Explicit @PartitionKey + @ClusteringKey |
| Dangerous queries | ALLOW FILTERING is a plain query flag — no guardrail | ALLOW FILTERING is one flag away | Structurally absent from the predicate DSL — one logged exception (findActive()) |
| Cross-table writes | BatchStatement is available; atomicity discipline is on you | Manual, non-atomic by default | @LookupIndex + logged batches, atomic by construction |
| How it wires up | Manual CqlSession lifecycle, no DI | Classpath scanning, dynamic proxies | Ktor plugin install, DI-native, traceable |
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.
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.
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.
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 →
$ ./gradlew dependencies | grep kandra
+--- ke.co.coinx.kandra:kandra-runtime:0.4.6