Warning

This article was automatically translated by OpenAI (gpt-4o-mini).It may be edited eventually, but please be aware that it may contain incorrect information at this time.

I will try to access Cassandra-compatible ScyllaDB using Spring Boot.
I will maintain a state that can be swapped with Cassandra without depending on ScyllaDB.

First, create a template for the app using Spring Initializr. It is exactly the same as creating an app for Cassandra.

curl https://start.spring.io/starter.tgz \
       -d artifactId=demo-scylla \
       -d baseDir=demo-scylla \
       -d packageName=com.example \
       -d dependencies=docker-compose,testcontainers,data-cassandra,web,actuator \
       -d type=maven-project \
       -d applicationName=DemoScyllaApplication | tar -xzvf -
cd demo-scylla

First, let's create the application code.

cat <<EOF> ./src/main/java/com/example/City.java
package com.example;

import java.util.UUID;

import org.springframework.data.cassandra.core.mapping.PrimaryKey;
import org.springframework.data.cassandra.core.mapping.Table;

@Table
public record City(@PrimaryKey UUID id, String name) {
}
EOF
cat <<EOF > ./src/main/java/com/example/CityRepository.java
package com.example;

import org.springframework.data.repository.ListCrudRepository;

public interface CityRepository extends ListCrudRepository<City, Integer> {

}
EOF
cat <<EOF > ./src/main/java/com/example/CityController.java
package com.example;

import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.util.IdGenerator;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CityController {

    private final CityRepository cityRepository;

    private final IdGenerator idGenerator;

    public CityController(CityRepository cityRepository, IdGenerator idGenerator) {
        this.cityRepository = cityRepository;
        this.idGenerator = idGenerator;
    }

    @GetMapping(path = "/cities")
    public List<City> getCities() {
        return this.cityRepository.findAll();
    }

    @PostMapping(path = "/cities")
    @ResponseStatus(HttpStatus.CREATED)
    public City postCities(@RequestBody City city) {
        City created = new City(this.idGenerator.generateId(), city.name());
        return cityRepository.save(created);
    }

}
EOF
cat <<EOF > ./src/main/java/com/example/AppConfig.java
        package com.example;

import com.datastax.oss.driver.api.core.CqlSession;
import com.datastax.oss.driver.api.core.CqlSessionBuilder;

import org.springframework.boot.autoconfigure.cassandra.CassandraProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.IdGenerator;
import org.springframework.util.JdkIdGenerator;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.CommonsRequestLoggingFilter;

@Configuration(proxyBeanMethods = false)
public class AppConfig {

    @Bean
    public IdGenerator idGenerator() {
        return new JdkIdGenerator();
    }

    @Bean
    public CommonsRequestLoggingFilter commonsRequestLoggingFilter() {
        CommonsRequestLoggingFilter loggingFilter = new CommonsRequestLoggingFilter();
        loggingFilter.setIncludeHeaders(true);
        loggingFilter.setIncludeClientInfo(true);
        return loggingFilter;
    }

    // (1)
    @Bean
    @ConditionalOnProperty(name = "spring.cassandra.keyspace-name", matchIfMissing = true)
    public CqlSession cqlSession(CqlSessionBuilder cqlSessionBuilder, CassandraProperties properties) {
        if (StringUtils.hasText(properties.getKeyspaceName())) {
            return cqlSessionBuilder.build();
        }
        String keyspaceName = "demo";
        try (CqlSession session = cqlSessionBuilder.build()) {
            session.execute(
                    "CREATE KEYSPACE IF NOT EXISTS %s WITH replication={'class':'NetworkTopologyStrategy', 'replication_factor':1}"
                            .formatted(keyspaceName));
        }
        return cqlSessionBuilder.withKeyspace(keyspaceName).build();
    }

}
EOF

(1) ... Normally, the keyspace should be created in advance, and the spring.cassandra.keyspace-name property should be set. Otherwise, an error will occur. In this case, I made it so that if the spring.cassandra.keyspace-name property is not set, the demo keyspace will be created automatically.

cat <<EOF > ./src/main/resources/application.properties
#spring.cassandra.keyspace-name=demo
logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=debug
server.shutdown=graceful
spring.application.name=demo
spring.cassandra.connection.connect-timeout=10s
spring.cassandra.connection.init-query-timeout=10s
spring.cassandra.request.timeout=10s
spring.cassandra.schema-action=create_if_not_exists
EOF

Next, let's create the test code.

cat <<EOF > ./src/test/java/com/example/DemoScyllaApplicationTests.java
package com.example;

import java.util.List;
import java.util.Set;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.testcontainers.junit.jupiter.Testcontainers;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.IdGenerator;
import org.springframework.util.SimpleIdGenerator;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestClient;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;

@Import(TestcontainersConfiguration.class)
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureJsonTesters
@TestMethodOrder(OrderAnnotation.class)
@Testcontainers(disabledWithoutDocker = true)
class DemoScyllaApplicationTests {

    @LocalServerPort
    int port;

    @Autowired
    RestClient.Builder restClientBuilder;

    RestClient restClient;

    @Autowired
    JacksonTester<City> cityTester;

    @Autowired
    JacksonTester<List<City>> listTester;

    @BeforeEach
    void setUp() {
        this.restClient = this.restClientBuilder.baseUrl("http://localhost:" + port)
            .defaultStatusHandler(new DefaultResponseErrorHandler() {
                @Override
                public void handleError(ClientHttpResponse response) {
                    // NO-OP
                }
            })
            .build();
    }

    @Test
    @Order(1)
    void getCities() throws Exception {
        ResponseEntity<List<City>> response = this.restClient.get()
            .uri("/cities")
            .retrieve()
            .toEntity(new ParameterizedTypeReference<>() {
            });
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(this.listTester.write(response.getBody())).isEqualToJson("""
                [
                  {
                    "id": "00000000-0000-0000-0000-000000000001",
                    "name": "Tokyo"
                  },
                  {
                    "id": "00000000-0000-0000-0000-000000000002",
                    "name": "Osaka"
                  },
                  {
                    "id": "00000000-0000-0000-0000-000000000003",
                    "name": "Kyoto"
                  }
                ]
                """);
    }

    @Test
    @Order(2)
    void postCities() throws Exception {
        {
            ResponseEntity<City> response = this.restClient.post().uri("/cities").body("""
                        {"name": "Toyama"}
                    """).contentType(MediaType.APPLICATION_JSON).retrieve().toEntity(City.class);
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
            assertThat(this.cityTester.write(response.getBody())).isEqualToJson("""
                    {
                      "id": "00000000-0000-0000-0000-000000000004",
                      "name": "Toyama"
                    }
                    """);
        }
        {
            ResponseEntity<List<City>> response = this.restClient.get()
                .uri("/cities")
                .retrieve()
                .toEntity(new ParameterizedTypeReference<>() {
                });
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(this.listTester.write(response.getBody())).isEqualToJson("""
                    [
                      {
                        "id": "00000000-0000-0000-0000-000000000001",
                        "name": "Tokyo"
                      },
                      {
                        "id": "00000000-0000-0000-0000-000000000002",
                        "name": "Osaka"
                      },
                      {
                        "id": "00000000-0000-0000-0000-000000000003",
                        "name": "Kyoto"
                      },
                      {
                        "id": "00000000-0000-0000-0000-000000000004",
                        "name": "Toyama"
                      }
                    ]
                    """);
        }
    }

    @TestConfiguration
    static class Config {

        @Bean
        @Primary
        public IdGenerator simpleIdGenerator() {
            return new SimpleIdGenerator();
        }

        @Bean
        public CommandLineRunner clr(CityRepository cityRepository, IdGenerator idGenerator) {
            return args -> cityRepository.saveAll(Set.of(new City(idGenerator.generateId(), "Tokyo"),
                    new City(idGenerator.generateId(), "Osaka"), new City(idGenerator.generateId(), "Kyoto")));
        }

    }

}
EOF
mkdir -p ./src/test/resources
cat <<EOF > ./src/test/resources/application-default.properties
spring.docker.compose.enabled=false
spring.output.ansi.enabled=always
EOF

Now, let's run the tests. At this stage, tests will be conducted using the Cassandra Docker image.

./mvnw clean package

Next, replace the image used in the tests with ScyllaDB's.

cat <<EOF > ./src/test/java/com/example/TestcontainersConfiguration.java
package com.example;

import org.testcontainers.containers.CassandraContainer;
import org.testcontainers.utility.DockerImageName;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

    @Bean
    @ServiceConnection
    CassandraContainer<?> cassandraContainer() {
        return new CassandraContainer<>(
                DockerImageName.parse("scylladb/scylla").asCompatibleSubstituteFor("cassandra"));
        // return new CassandraContainer<>(DockerImageName.parse("cassandra:latest"));
    }

}
EOF

When the tests are run again, they will now use the ScyllaDB Docker image.

./mvnw clean package

Change the Docker Compose to also use ScyllaDB.

cat <<EOF > ./compose.yaml
services:
  cassandra:
    image: 'scylladb/scylla'
    ports:
    - '9042'
    labels:
      org.springframework.boot.service-connection: cassandra
EOF

Note

The original compose.yaml was as follows:

services:
  cassandra:
    image: 'cassandra:latest'
    environment:
      - 'CASSANDRA_DC=dc1'
      - 'CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch'
    ports:
      - '9042'

Start the application using Spring Boot's Docker Compose support.

./mvnw spring-boot:run

Verify operation.

$ curl http://localhost:8080/cities -H "Content-Type: application/json" -d '{"name": "Tokyo"}'
{"id":"818c40b4-123d-4964-a731-6a8901eaa57c","name":"Tokyo"} 

$ curl http://localhost:8080/cities -H "Content-Type: application/json" -d '{"name": "Osaka"}'
{"id":"afaba7c6-3b0b-4aa4-88e8-9bca2de83b49","name":"Osaka"} 

$ curl http://localhost:8080/cities
[{"id":"818c40b4-123d-4964-a731-6a8901eaa57c","name":"Tokyo"},{"id":"afaba7c6-3b0b-4aa4-88e8-9bca2de83b49","name":"Osaka"},{"id":"d547db6a-6507-4760-8bf1-6f32506925ca","name":"Tokyo"},{"id":"143b5bf0-680a-40e5-963e-997565af034a","name":"Toyama"}]

If you run Docker Compose yourself without using Spring Boot's Docker Compose support:

docker-compose down
docker-compose up -d
docker exec -it demo-scylla-cassandra-1 cqlsh -e "CREATE KEYSPACE IF NOT EXISTS foo WITH replication={'class':'NetworkTopologyStrategy', 'replication_factor':1} AND TABLETS = {'enabled': false}"
./mvnw clean package -DskipTests
CASSANDRA_PORT=$(docker inspect --format '{{(index (index .NetworkSettings.Ports "9042/tcp") 0).HostPort}}' demo-scylla-cassandra-1)
java -jar target/demo-scylla-0.0.1-SNAPSHOT.jar --spring.cassandra.contact-points=localhost:${CASSANDRA_PORT} --spring.cassandra.local-datacenter=datacenter1 --spring.cassandra.keyspace-name=foo

I accessed ScyllaDB with Spring Boot. It worked as a drop-in replacement for Cassandra.

Found a mistake? Update the entry.
Share this article: