Kandra 0.4.6
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.
@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
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(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, 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
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.
Running more than one datacenter? Multi-DC recipe ›
For teams evaluating an ORM
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.
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.
For developers
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.
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,
)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 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