Cassandra Database Integration with Kora¶
This guide introduces Cassandra-compatible persistence with Kora. It covers how repository interfaces use @Repository and @Query, how DAO records map to CQL rows, how Cassandra configuration
creates a session in the application graph, and how the same HTTP API shape can use a distributed wide-column database instead of in-memory storage.
If you want to check your progress along the way, use the finished working example: Kora Java Database Cassandra App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin Database Cassandra App.
What You'll Build¶
You will turn the user HTTP API into a Cassandra-backed application with:
- a CQL schema that creates a
userstable - a Cassandra DAO model with
@EntityCassandraand explicit column mapping - a Kora
@Repositoryinterface with CQL queries for create, read, list, update, and delete operations - service logic that preserves the HTTP guide behavior while respecting Cassandra mutation semantics
- HOCON configuration for Cassandra contact points, datacenter, keyspace, and credentials
- Scylla Testcontainers tests that verify persistence against a Cassandra-compatible database
What You'll Need¶
- JDK 17 or later
- Cassandra-compatible database, such as Apache Cassandra or ScyllaDB
- Docker for the integration tests
- Gradle 7+
- A text editor or IDE
- Completed HTTP Server guide
Prerequisites¶
Required: Complete HTTP Server Guide
This guide assumes you have completed the HTTP Server guide and have a working HTTP API project with UserController, UserService, and an in-memory repository implementation.
If you have not completed the HTTP server guide yet, do that first, because this guide replaces the in-memory persistence with Cassandra-compatible storage.
Overview¶
Moving from in-memory storage to Apache Cassandra changes the persistence layer without changing the public HTTP API contract. The controller can still
expose /users, the service can still return UserResponse, and the repository boundary becomes the place where application operations are translated into CQL.
Cassandra is not a relational database with SQL joins, foreign keys, sequences, and row-level update counts. It is a distributed wide-column database designed around partitioned access patterns, high write throughput, tunable consistency, and horizontal scaling. That means the guide is not a mechanical JDBC rewrite. It keeps the same CRUD-shaped learning goal, but adapts the implementation to Cassandra rules.
How Persistence Changes the Application¶
An in-memory repository is useful while learning HTTP controllers, but it disappears on restart, cannot be shared between application instances, and does not exercise real storage behavior.
A database-backed application introduces new concerns:
- records must survive application restarts
- schema must be created before repositories run
- queries must match the database access model
- configuration must point to different clusters in local, test, and deployed environments
- tests should verify real driver, query, and mapping behavior
This guide introduces the full persistence boundary: table schema, Cassandra session configuration, repository CQL, generated repository implementation, row mapping, and container-based tests.
Cassandra as the Source of Truth¶
In this guide, Cassandra becomes the source of truth for user data. The repository no longer stores users in a local map; it reads and writes rows in a users table.
Cassandra data modeling starts from query patterns. A table is usually designed for the reads and writes the service needs, not for a normalized entity graph. This guide keeps the table intentionally small:
idis the partition keyname,email, andcreated_atare regular columns- reads by id are direct partition reads
- listing users is acceptable for a learning guide, but in production it should be designed around a bounded query pattern
That last point matters. SELECT ... FROM users is convenient in a small tutorial table, but production Cassandra systems usually avoid unbounded table scans.
Cassandra Repositories¶
CQL is the Cassandra Query Language. It looks familiar if you know SQL, but it follows Cassandra's storage model. Kora Cassandra repositories keep CQL explicit while generating the driver boilerplate.
You declare a repository interface, annotate methods with @Query, and Kora generates an implementation that:
- prepares CQL statements
- binds named parameters to positional driver parameters
- executes statements through the configured Cassandra session
- applies telemetry around each query
- maps rows into Java records or Kotlin data classes
The explicit CQL stays visible in code review, while the repetitive driver code is generated.
Entities and Row Mapping¶
HTTP DTOs and database rows should stay separate. UserRequest and UserResponse describe API input and output. UserDAO describes the database row shape.
Because UserRequest and UserResponse are still HTTP JSON DTOs, the application keeps @Json on those classes. Cassandra mapping belongs on UserDAO; JSON mapping belongs on the request and
response DTOs that form the API boundary.
Kora maps Cassandra rows into typed Java records. @EntityCassandra asks Kora to generate Cassandra mappers for the DAO model directly, and @Column makes each CQL column name explicit. This is
useful when Java uses createdAt and the table uses created_at.
This guide uses Instant for created_at because Cassandra timestamp maps naturally to an instant in time.
Cassandra Mutation Semantics¶
JDBC updates often return an affected row count. Cassandra writes are different: INSERT, UPDATE, and DELETE are mutations and do not naturally behave like SQL update-count operations.
To keep the HTTP API behavior from the previous guide, the service checks existence with findById(...) before update and delete. That lets the API still return 404 for missing users, while the
repository methods remain idiomatic Cassandra mutations.
In a real system, you may choose another design:
- accept idempotent deletes
- use lightweight transactions for conditional writes when the consistency cost is justified
- model commands so the client already knows whether absence is an error
The guide keeps the behavior familiar so the storage differences are easier to see.
Runtime Configuration¶
Kora wires Cassandra through CassandraDatabaseModule. The application graph owns the configured session, repositories depend on it, and tests override contact points and keyspace values with Scylla
Testcontainers.
The core settings are:
- contact points: where the driver connects
- datacenter: local DC used by the load-balancing policy
- keyspace: logical namespace for tables
- credentials: username and password when authentication is enabled
- request timeout: maximum time for a query attempt
Keeping these values outside code makes the same application runnable against local Scylla, test containers, staging Cassandra, or production clusters.
Persistence Testing¶
Mocks cannot prove that CQL syntax is valid, that a table exists, or that the generated Cassandra mapper reads the right columns.
The practical flow is:
- add Cassandra and Scylla Testcontainers dependencies
- add
CassandraDatabaseModuleto the Kora application - define the
userstable in CQL - define a Cassandra DAO with
@EntityCassandra - create a Kora Cassandra repository with CQL queries
- refactor the service to use Cassandra persistence
- verify repository behavior with Scylla Testcontainers
Dependencies¶
Add Cassandra support and Scylla Testcontainers verification dependencies.
Add to the dependencies block in build.gradle:
Modules¶
Update your Application interface to include the Cassandra module.
src/main/java/ru/tinkoff/kora/guide/databasecassandra/Application.java:
package ru.tinkoff.kora.guide.databasecassandra;
import ru.tinkoff.kora.application.graph.KoraApplication;
import ru.tinkoff.kora.common.KoraApp;
import ru.tinkoff.kora.config.hocon.HoconConfigModule;
import ru.tinkoff.kora.database.cassandra.CassandraDatabaseModule;
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule;
import ru.tinkoff.kora.json.module.JsonModule;
import ru.tinkoff.kora.logging.logback.LogbackModule;
@KoraApp
public interface Application extends
HoconConfigModule,
JsonModule,
LogbackModule,
CassandraDatabaseModule, // <----- Connected module
UndertowHttpServerModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
src/main/kotlin/ru/tinkoff/kora/guide/databasecassandra/Application.kt:
package ru.tinkoff.kora.guide.databasecassandra
import ru.tinkoff.kora.application.graph.KoraApplication
import ru.tinkoff.kora.common.KoraApp
import ru.tinkoff.kora.config.hocon.HoconConfigModule
import ru.tinkoff.kora.database.cassandra.CassandraDatabaseModule
import ru.tinkoff.kora.http.server.undertow.UndertowHttpServerModule
import ru.tinkoff.kora.json.module.JsonModule
import ru.tinkoff.kora.logging.logback.LogbackModule
@KoraApp
interface Application :
HoconConfigModule,
JsonModule,
LogbackModule,
CassandraDatabaseModule, // <----- Connected module
UndertowHttpServerModule
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
Database entity¶
Replace the old in-memory storage model with a Cassandra DAO model used by repository mappings.
src/main/java/ru/tinkoff/kora/guide/databasecassandra/repository/UserDAO.java:
package ru.tinkoff.kora.guide.databasecassandra.repository;
import java.time.Instant;
import ru.tinkoff.kora.database.cassandra.annotation.EntityCassandra;
import ru.tinkoff.kora.database.common.annotation.Column;
import ru.tinkoff.kora.database.common.annotation.Table;
@EntityCassandra
@Table("users")
public record UserDAO(
@Column("id") String id,
@Column("name") String name,
@Column("email") String email,
@Column("created_at") Instant createdAt) {}
src/main/kotlin/ru/tinkoff/kora/guide/databasecassandra/repository/UserDAO.kt:
package ru.tinkoff.kora.guide.databasecassandra.repository
import java.time.Instant
import ru.tinkoff.kora.database.cassandra.annotation.EntityCassandra
import ru.tinkoff.kora.database.common.annotation.Column
import ru.tinkoff.kora.database.common.annotation.Table
@EntityCassandra
@Table("users")
data class UserDAO(
@field:Column("id") val id: String,
@field:Column("name") val name: String,
@field:Column("email") val email: String,
@field:Column("created_at") val createdAt: Instant,
)
@EntityCassandra tells Kora to generate Cassandra row and result-set mappers for this DAO directly. That keeps mapper generation explicit and avoids slower late-generation rounds when the repository
graph first requests a mapper.
Repository¶
Remove the old InMemoryUserRepository from the HTTP server guide and create a Cassandra repository with CQL queries.
src/main/java/ru/tinkoff/kora/guide/databasecassandra/repository/UserRepository.java:
package ru.tinkoff.kora.guide.databasecassandra.repository;
import jakarta.annotation.Nullable;
import java.util.List;
import ru.tinkoff.kora.database.cassandra.CassandraRepository;
import ru.tinkoff.kora.database.common.annotation.Query;
import ru.tinkoff.kora.database.common.annotation.Repository;
@Repository
public interface UserRepository extends CassandraRepository {
@Query("SELECT id, name, email, created_at FROM users")
List<UserDAO> findAll();
@Query("SELECT id, name, email, created_at FROM users WHERE id = :id")
@Nullable
UserDAO findById(String id);
@Query("""
INSERT INTO users(id, name, email, created_at)
VALUES (:user.id, :user.name, :user.email, :user.createdAt)
""")
void save(UserDAO user);
@Query("""
UPDATE users
SET name = :user.name, email = :user.email, created_at = :user.createdAt
WHERE id = :user.id
""")
void update(UserDAO user);
@Query("DELETE FROM users WHERE id = :id")
void deleteById(String id);
}
After compilation, Kora generates the repository implementation and mappers:
guides/guide-database-cassandra-app/build/generated/sources/annotationProcessor/java/main/ru/tinkoff/kora/guide/databasecassandra/repository/$UserRepository_Impl.java
guides/guide-database-cassandra-app/build/generated/sources/annotationProcessor/java/main/ru/tinkoff/kora/guide/databasecassandra/repository/$UserDAO_CassandraRowMapper.java
guides/guide-database-cassandra-app/build/generated/sources/annotationProcessor/java/main/ru/tinkoff/kora/guide/databasecassandra/repository/$UserDAO_ListCassandraResultSetMapper.java
guides/kotlin/guide-kotlin-database-cassandra-app/build/generated/ksp/main/kotlin/ru/tinkoff/kora/guide/databasecassandra/repository/$UserRepository_Impl.kt
guides/kotlin/guide-kotlin-database-cassandra-app/build/generated/ksp/main/kotlin/ru/tinkoff/kora/guide/databasecassandra/repository/$UserDAO_CassandraRowMapper.kt
guides/kotlin/guide-kotlin-database-cassandra-app/build/generated/ksp/main/kotlin/ru/tinkoff/kora/guide/databasecassandra/repository/$UserDAO_ListCassandraResultSetMapper.kt
This shortened generated repository excerpt shows how named CQL parameters become DataStax driver statement parameters:
private static final QueryContext QUERY_CONTEXT_3 = new QueryContext(
"INSERT INTO users(id, name, email, created_at)\n"
+ "VALUES (:user.id, :user.name, :user.email, :user.createdAt)\n",
"INSERT INTO users(id, name, email, created_at)\n"
+ "VALUES (?, ?, ?, ?)\n",
"UserRepository.save"
);
@Override
public void save(UserDAO user) {
var _query = QUERY_CONTEXT_3;
var _ctxCurrent = Context.current();
var _telemetry = this._connectionFactory.telemetry().createContext(_ctxCurrent, _query);
var _session = this._connectionFactory.currentSession();
var _stmt = _session.prepare(_query.sql()).boundStatementBuilder();
_stmt.setString(0, user.id());
_stmt.setString(1, user.name());
_stmt.setString(2, user.email());
_stmt.setInstant(3, user.createdAt());
var _s = _stmt.build();
try {
var _rs = _session.execute(_s);
_telemetry.close(null);
} catch (Exception _e) {
_telemetry.close(_e);
throw _e;
}
}
private val _queryContext_3: QueryContext = QueryContext(
"INSERT INTO users(id, name, email, created_at) VALUES (:user.id, :user.name, :user.email, :user.createdAt)",
"INSERT INTO users(id, name, email, created_at) VALUES (?, ?, ?, ?)",
"UserRepository.save"
)
override fun save(user: UserDAO) {
val _query = _queryContext_3
val _ctxCurrent = Context.current()
val _telemetry = this._cassandraConnectionFactory.telemetry().createContext(_ctxCurrent, _query)
val _session = this._cassandraConnectionFactory.currentSession()
var _stmt = _session.prepare(_query.sql()).boundStatementBuilder()
_stmt.setString(0, user.id)
_stmt.setString(1, user.name)
_stmt.setString(2, user.email)
_stmt.setInstant(3, user.createdAt)
val _s = _stmt.build()
try {
_session.execute(_s)
_telemetry.close(null)
} catch (_e: Exception) {
_telemetry.close(_e)
throw _e
}
}
This shortened generated row mapper excerpt also shows why explicit column names matter:
var _idx_id = _row.firstIndexOf("id");
var _idx_name = _row.firstIndexOf("name");
var _idx_email = _row.firstIndexOf("email");
var _idx_createdAt = _row.firstIndexOf("created_at");
String id = _row.getString(_idx_id);
String name = _row.getString(_idx_name);
String email = _row.getString(_idx_email);
Instant createdAt = _row.getInstant(_idx_createdAt);
return new UserDAO(id, name, email, createdAt);
val _idx_id = _row.columnDefinitions.firstIndexOf("id")
val _idx_name = _row.columnDefinitions.firstIndexOf("name")
val _idx_email = _row.columnDefinitions.firstIndexOf("email")
val _idx_createdAt = _row.columnDefinitions.firstIndexOf("created_at")
var id: String? = _row.getString(_idx_id)
if (_row.isNull(_idx_id) || id == null) {
throw NullPointerException("Required field id is not nullable but row has null")
}
var name: String? = _row.getString(_idx_name)
if (_row.isNull(_idx_name) || name == null) {
throw NullPointerException("Required field name is not nullable but row has null")
}
var email: String? = _row.getString(_idx_email)
if (_row.isNull(_idx_email) || email == null) {
throw NullPointerException("Required field email is not nullable but row has null")
}
var createdAt: Instant? = _row.getInstant(_idx_createdAt)
if (_row.isNull(_idx_createdAt) || createdAt == null) {
throw NullPointerException("Required field created_at is not nullable but row has null")
}
val _result = UserDAO(id, name, email, createdAt)
return _result
This is the best place to debug CQL binding and row mapping because it shows exactly what Kora compiled from @Repository, @Query, @EntityCassandra, and @Column.
Refactor the Service¶
At this step, refactor the existing UserService from the HTTP server guide.
Important rules:
- Keep the same public service contracts used by
UserController. - Generate ids in the application because Cassandra does not use PostgreSQL-style
RETURNING id. - Check existence before update/delete if your HTTP API should return
404. - Keep
UserControllerand its HTTP contracts unchanged.
Update src/main/java/ru/tinkoff/kora/guide/databasecassandra/service/UserService.java:
package ru.tinkoff.kora.guide.databasecassandra.service;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import ru.tinkoff.kora.common.Component;
import ru.tinkoff.kora.guide.databasecassandra.dto.UserRequest;
import ru.tinkoff.kora.guide.databasecassandra.dto.UserResponse;
import ru.tinkoff.kora.guide.databasecassandra.repository.UserDAO;
import ru.tinkoff.kora.guide.databasecassandra.repository.UserRepository;
import ru.tinkoff.kora.http.server.common.HttpServerResponseException;
@Component
public final class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public UserResponse createUser(UserRequest request) {
var user = new UserDAO(UUID.randomUUID().toString(), request.name(), request.email(), Instant.now());
userRepository.save(user);
return toResponse(user);
}
public Optional<UserResponse> getUser(String id) {
return Optional.ofNullable(userRepository.findById(id)).map(this::toResponse);
}
public List<UserResponse> getUsers(int page, int size, String sort) {
return userRepository.findAll().stream()
.map(this::toResponse)
.sorted(getComparator(sort))
.skip((long) page * size)
.limit(size)
.toList();
}
public UserResponse updateUser(String id, UserRequest request) {
var existing = userRepository.findById(id);
if (existing == null) {
throw HttpServerResponseException.of(404, "User not found");
}
var updated = new UserDAO(id, request.name(), request.email(), existing.createdAt());
userRepository.update(updated);
return toResponse(updated);
}
public void deleteUser(String id) {
if (userRepository.findById(id) == null) {
throw HttpServerResponseException.of(404, "User not found");
}
userRepository.deleteById(id);
}
private UserResponse toResponse(UserDAO user) {
return new UserResponse(user.id(), user.name(), user.email(), user.createdAt());
}
private Comparator<UserResponse> getComparator(String sort) {
return switch (sort.toLowerCase()) {
case "name" -> Comparator.comparing(UserResponse::name);
case "email" -> Comparator.comparing(UserResponse::email);
case "createdat" -> Comparator.comparing(UserResponse::createdAt);
default -> Comparator.comparing(UserResponse::name);
};
}
}
Controller stays as-is
Do not rewrite UserController in this guide. Keep the controller from http-server.md unchanged, so only the repository implementation is replaced under the hood.
Configuration¶
Create src/main/resources/application.conf:
For the full configuration reference, see Database Cassandra.
cassandra {
auth {
login = ${CASSANDRA_USER} //(1)!
password = ${CASSANDRA_PASS} //(2)!
}
basic {
contactPoints = ${CASSANDRA_CONTACT_POINTS} //(3)!
dc = ${CASSANDRA_DC} //(4)!
sessionKeyspace = ${CASSANDRA_KEYSPACE} //(5)!
request {
timeout = "5s" //(6)!
}
}
telemetry.logging.enabled = true //(7)!
}
- Login used by the Cassandra connection. Required value from
CASSANDRA_USER. - Database user password. Required value from
CASSANDRA_PASS. - Cassandra contact points used to open sessions. Required value from
CASSANDRA_CONTACT_POINTS. - Value for
cassandra.basic.dc. Required value fromCASSANDRA_DC. - Value for
cassandra.basic.sessionKeyspace. Required value fromCASSANDRA_KEYSPACE. - Value for
cassandra.basic.request.timeout. - Enables Cassandra client telemetry logging.
cassandra:
auth:
login: ${CASSANDRA_USER} #(1)!
password: ${CASSANDRA_PASS} #(2)!
basic:
contactPoints: ${CASSANDRA_CONTACT_POINTS} #(3)!
dc: ${CASSANDRA_DC} #(4)!
sessionKeyspace: ${CASSANDRA_KEYSPACE} #(5)!
request:
timeout: "5s" #(6)!
telemetry:
logging:
enabled: true #(7)!
- Login used by the Cassandra connection. Required value from
CASSANDRA_USER. - Database user password. Required value from
CASSANDRA_PASS. - Cassandra contact points used to open sessions. Required value from
CASSANDRA_CONTACT_POINTS. - Value for
cassandra.basic.dc. Required value fromCASSANDRA_DC. - Value for
cassandra.basic.sessionKeyspace. Required value fromCASSANDRA_KEYSPACE. - Value for
cassandra.basic.request.timeout. - Enables Cassandra client telemetry logging.
For local Scylla, typical values are:
export CASSANDRA_CONTACT_POINTS=127.0.0.1:9042
export CASSANDRA_USER=cassandra
export CASSANDRA_PASS=cassandra
export CASSANDRA_DC=datacenter1
export CASSANDRA_KEYSPACE=guide
Database Setup¶
Docker Compose¶
Create a docker-compose.yml file in the application module directory:
services:
scylla:
image: scylladb/scylla:2025.3
command: ["--smp", "1", "--memory", "750M", "--overprovisioned", "1", "--developer-mode", "1"]
ports:
- "9042:9042"
Start the database:
Create a keyspace and table:
CREATE KEYSPACE IF NOT EXISTS guide
WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};
CREATE TABLE IF NOT EXISTS guide.users
(
id TEXT,
name TEXT,
email TEXT,
created_at TIMESTAMP,
PRIMARY KEY (id)
);
Run Application¶
Check Application¶
Get all users:
Get user by ID:
Create a new user:
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name": "Bob Johnson", "email": "bob@example.com"}'
Update a user:
curl -X PUT http://localhost:8080/users/{id} \
-H "Content-Type: application/json" \
-d '{"name": "Bob Smith", "email": "bob.smith@example.com"}'
Delete a user:
Best Practices¶
- Design Cassandra tables from query patterns, not from normalized relational models.
- Keep CQL in repository methods and business decisions in the service layer.
- Use
@EntityCassandraon DAO models so Kora generates Cassandra mappers explicitly. - Add
@Columnto every DAO record component so database mappings stay explicit. - Avoid unbounded table scans in production; model list endpoints around bounded partitions or dedicated read tables.
- Treat Cassandra mutations as idempotent by default unless you deliberately use conditional writes.
- Use Scylla or Cassandra Testcontainers tests to verify CQL, schema, and generated mappers.
- Inspect generated repository implementations when CQL binding or row mapping is unclear.
Summary¶
You replaced the in-memory repository from the HTTP guide with a Cassandra-backed repository, added Cassandra configuration, created a CQL table, and verified persistence with Scylla Testcontainers.
The HTTP contract stays stable while the persistence layer becomes distributed and Cassandra-compatible. You also inspected generated Cassandra code to see how Kora turns repository annotations into prepared statements, telemetry calls, and row mappers.
Key Concepts¶
- how Kora Cassandra repositories are declared
- how DAO records use
@EntityCassandraand@Column - how Cassandra configuration creates a session in the application graph
- how Cassandra mutation semantics differ from JDBC update counts
- how generated repository implementations bind CQL parameters and map rows
- how Scylla Testcontainers verifies Cassandra-compatible persistence
Troubleshooting¶
Connection Issues:
- Ensure Cassandra or Scylla is running and reachable from your app.
- Verify
CASSANDRA_CONTACT_POINTS,CASSANDRA_USER,CASSANDRA_PASS,CASSANDRA_DC, andCASSANDRA_KEYSPACE. - Ensure the configured datacenter matches the running cluster.
Schema Issues:
- Ensure the keyspace exists before application startup.
- Ensure the
userstable exists in the configured keyspace. - Verify CQL column names match
UserDAO@Columnmappings.
Compilation Errors:
- Ensure
ru.tinkoff.kora:database-cassandrais added. - Ensure annotation processing is enabled for Kora.
- Use
@EntityCassandrafor DAO models that repositories return.
Runtime Query Errors:
- Check that queries match Cassandra access rules.
- Avoid filtering or ordering patterns that Cassandra cannot execute without an appropriate table design.
- Review generated
$UserRepository_Impl.javaor$UserRepository_Impl.ktfor exact parameter binding.
What's Next?¶
- Caching to reduce repeated reads from Cassandra-compatible storage.
- Observability to monitor database-backed request paths with metrics, traces, logs, and probes.
- Database JDBC if you want to compare the same CRUD persistence lesson with relational SQL.
- Messaging with Kafka when database writes should also publish events.
- Testing with JUnit for component-level tests that do not assume the JDBC-specific testing guides.
Help¶
If you encounter issues:
- compare with Kora Java Database Cassandra App and Kora Kotlin Database Cassandra App
- check the Database Cassandra documentation
- check the Database Common documentation
- read the Apache Cassandra documentation
- read the ScyllaDB documentation