Spring Boot Microservice with Cassandra - containerized by Docker

In this article, we will explore "Spring Boot Microservices: Cassandra Integration and Dockerization". We will guide you through the process of developing and testing a Spring Boot microservice named "bookstore-service". This microservice will be packaged in a Docker container and will perform CRUD (Create, Read, Update, Delete) operations on a Cassandra database.

Brief Introduction

Spring Boot: Spring Boot is a framework for building microservices in the Java language.

Docker: Docker is a platform for containerizing the applications - it packages an application and its dependencies in a container, which is quite lightweight and portable.

Cassandra: Cassandra is a NoSQL database that is suitable for handling large amounts of different nature data.

Prerequisites

  • JDK 19
  • Gradle v 7.6 (compatible to JDK19)
  • Java IDE, VS Code, IntelliJ, Eclipse or other
  • Docker Desktop installed

Before diving into our main task, "Spring Boot Microservices: Cassandra Integration and Dockerization", it's important to have a fundamental understanding of Java, Spring Boot, Gradle, and Docker. If you're not familiar with these technologies, don't worry—you can refer to my previous guide to learn how to set up the Spring Boot development environment and fill in any gaps in your knowledge.

Let's get our hand dirty and move to the coding part.

Configurations

build.gradle:

After initializing the spring-boot-gradle application, we need to add the required dependencies in the build.gradle file. Most of the build configurations are filled by spring initializr, like plugins, repositories, and some dependencies like org.springframework.boot:spring-boot-starter-web, however we have to add couple of additional dependencies like spring-data-cassandra' and 'spring-boot-starter-data-cassandra to work with cassandra.

Of course you can add more flesh as the application grows.

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.0.2-SNAPSHOT'
	id 'io.spring.dependency-management' version '1.1.0'
}
group = 'com.azamakram.github'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '19'
configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}
repositories {
	mavenCentral()
	maven { url 'https://repo.spring.io/milestone' }
	maven { url 'https://repo.spring.io/snapshot' }
}
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation group: 'javax.validation', name: 'validation-api', version: '2.0.1.Final'
	implementation group: 'org.springframework.data', name: 'spring-data-cassandra', version: '4.0.1'
	testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-cassandra', version: '3.0.1'
	annotationProcessor 'org.projectlombok:lombok'
}
tasks.named('test') {
	useJUnitPlatform()
}

application.yaml:

We define the application specific configurations here, such as the URL to access our microservice via HTTP request, and some cassandra configurations.

server:
  servlet:
    context-path: /bookstore/${application.api.version}
  port: 8080
application:
  api:
    version: v1
spring:
  cassandra:
    port: 9042
    contact-points: 127.0.0.1
    keyspace_name: ks_bookstore
    schema-action: CREATE_IF_NOT_EXISTS
    password: cassandra
    username: cassandra

BookStoreApplication

BookStoreApplication is the entry point of the application - @SpringBootApplication annotation enables the spring boot auto configuration and component scan.

@SpringBootApplication
public class BookStoreApplication {
	public static void main(String[] args) {
		SpringApplication.run(BookStoreApplication.class, args);
	}
}

Model

BookStoreInput:

Input event model to accept json payload.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookStoreInput {
    private UUID uuid;
    private String title;
    private String writer;
}

BookStoreOutput:

For the simplicity I define output json same as input, but we can always add/remove to return different fields in response.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookStoreOutput {
    private UUID uuid;
    private String title;
    private String writer;
}

Entity:

Entity class follows the traditional "POJO" model and each field maps to a CQL column. @Table("bookstore") will create a table named "bookstore".

@PrimaryKeyColumn(name = "uuid", type = PrimaryKeyType.PARTITIONED) defines a partition key, which is a unique identifier for a bookstore rows.

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table("bookstore")
public class BookStore {

    @PrimaryKeyColumn(name = "uuid", type = PrimaryKeyType.PARTITIONED)
    private UUID uuid;

    @Column(value = "title")
    private String title;

    @Column(value = "writer")
    private String writer;
}

BookStoreOutput:

For the simplicity I define output json same as input, but we can always add/remove to return different fields in response.

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BookStoreOutput {
    private UUID uuid;
    private String title;
    private String writer;
}

BookNotFoundException:

We define a customized exception response for HttpStatus.NOT_FOUND (if requested book record not found)

@ResponseStatus(HttpStatus.NOT_FOUND)
public class BookNotFoundException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    public BookNotFoundException(String message, Throwable throwable) {
        super(message, throwable);
    }

    public BookNotFoundException(String message) {
        super(message);
    }
}

BookStoreController

BookStoreController is the controller class, which exposes different REST API endpoints and methods. @RestController annotation over BookStoreController tells the spring boot that all the REST API calls should be routed here.

@RequestMapping(path = "/books") adds /books mapping to the core API url (defined in application.yaml)

BookStoreController exposes following HTTP Methods,

  • GET http://<api-url>/bookstore/v1/books: to fetch all books in the store
  • GET http://<api-url>/bookstore/v1/books/<bookid>: to fetch a book with specific id
  • POST http://<api-url>/bookstore/v1/books: to add a new book in the store
  • PUT http://<api-url>/bookstore/v1/books/<bookid>: to edit a book record in the store
  • DELETE http://<api-url>/bookstore/v1/books/<bookid>: to add a new book in the store

I will not explain all the methods one-by-one, let's pick one of them, saveBook() - whose explanation will cover others as well.

saveBook() has an annotation, @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - which indicates that it handles HTTP POST request, and it accepts JSON input payload and produces a response as JSON object.

@Valid inside method argument list, validates the data types of each JSON field. For this purpose I am using a dependency, 'javax.validation', name: 'validation-api' in build.gradle.

@RequestBody annotation maps the HTTP request body to BookStoreInput object. I also use final keyword for this because we want to protect input objects from any change.

bookStoreService.saveBookStore(input) calls a method in the BootStoreService, which is a service layer of BookStore application and acts as a bridge between cassandra repository and controller. I will cover the service class in the next section.

ResponseEntity maps the application output object to HTTP respone object, including headers.

In this example I assume to get a favorable response from the service layer, so I return HttpStatus.OK as a HTTP response code. But in reality we could have some failure scenarios as well, which should be covered.

@Slf4j
@RestController
@RequestMapping(path = "/books")
public class BookStoreController {
    private static Logger logger = LoggerFactory.getLogger(BookStoreController.class);
 
    @Autowired
    BookStoreService bookStoreService;
    
    @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> getAllBooks() {
        logger.info("Reading all books");
        return new ResponseEntity<>(bookStoreService.getAllBookStores(), HttpStatus.OK);
    }

    @GetMapping(path = "/{uuid}",
            produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> getBookById(@PathVariable(value = "uuid") final UUID uuid) {
        logger.info("Reading book information by Id");
        return new ResponseEntity<>(bookStoreService.getBookStoreById(uuid), HttpStatus.OK);
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> saveBook(@Valid @RequestBody final BookStoreInput input) {
        logger.info("Saving book");
        return new ResponseEntity<>(bookStoreService.saveBookStore(input), HttpStatus.OK);
    }

    @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<?> updateBook(@Valid @RequestBody final BookStoreInput input) {
        logger.info("Update book");
        return new ResponseEntity<>(bookStoreService.updateBookStore(input), HttpStatus.OK);
    }

    @DeleteMapping(path = "/{uuid}")
    public ResponseEntity<?> deleteBook(@PathVariable(value = "uuid") final UUID uuid) {
        logger.info("Update book");
        bookStoreService.deleteBookStore(uuid);
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

BookStoreService

BookStoreServiceImpl class implements all the methods exposed by the BookStoreService interface. BookStoreServiceImpl acts as a bridge between cassandra repository and controller. It also maps database object to output object models. You can check the details of the object mapping in the ModelConverterUtil class.

I inject the BookStoreRepository bean (explained in next section) into the service class. @Autowired annotation provisions the spring boot to resolve and inject the required bean into the context.

@Component
public class BookStoreServiceImpl implements BookStoreService {

    @Autowired
    private BookStoreRepository bookStoreRepository;

    public BookStoreServiceImpl(BookStoreRepository bookStoreRepository) {
        this.bookStoreRepository = bookStoreRepository;
    }

    @Override
    public List<BookStoreOutput> getAllBookStores() {
        return ModelConverterUtil.convertEntityListToOutputList(bookStoreRepository.findAll());
    }

    @Override
    public BookStoreOutput getBookStoreById(UUID id) {
        return bookStoreRepository.findByUuid(id)
            .map(entity -> ModelConverterUtil.convertEntityToOutput(entity))
            .orElseThrow( ()-> new BookNotFoundException(String.format("Book not found for id: %s", id)));
    }

    @Override
    public BookStoreOutput saveBookStore(BookStoreInput input) {
        BookStore entity = ModelConverterUtil.convertInputToEntity(input);
        BookStore saved = bookStoreRepository.save(entity);
        if(saved == null) {
            throw new NullPointerException();
        }
        return ModelConverterUtil.convertEntityToOutput(saved);
    }

    @Override
    public BookStoreOutput updateBookStore(BookStoreInput input) {
        return ModelConverterUtil.convertEntityToOutput(
                bookStoreRepository.save(
                        ModelConverterUtil.convertInputToEntity(input)));
    }

    @Override
    public void deleteBookStore(UUID uuid) {
        bookStoreRepository.deleteById(uuid);
    }

}

BookStoreRepository

I extended BookStoreRepository interface from CassandraRepository .

public interface BookStoreRepository extends CassandraRepository<BookStore, UUID> {
    Optional<BookStore> findByUuid(UUID uuid);
}

BookStoreCassandraConfig

BookStoreCassandraConfig really does the magic for us to configure cassandra, create keyspace, table etc. We extend the BookStoreCassandraConfig from AbstractCassandraConfiguration, which is a "Base class for Spring Data Cassandra configuration using JavaConfig" [Copied text].

@EnableCassandraRepositories annotation enables cassandra repository. We pass the package name to our repository as a basePackage parameter, which tells cassandra where to look for a repository object.

Most of the method names in the class are self-explanatory, I will write explanations of a few of them.

  • getKeyspaceCreations(): create a keyspace in cassandra
  • getEntityBasePackages(): where to look for Entity (Table) structure to create.
  • CqlSessionFactoryBean: we inject a CqlSessionFactoryBean bean, which is a singleton object to create and configure CQL session.

When we run the application, BookStoreCassandraConfig makes sure that we have

  • cassanadra configured
  • keyspace created
  • a bookstore table is created

We will connect to CQL in the coming section and verify if we get the keyspace and table created or not.

@Configuration
@EnableCassandraRepositories(basePackages = {"com.azamakram.github.BookStore.repository"})
public class BookStoreCassandraConfig extends AbstractCassandraConfiguration {
    @Value("${spring.cassandra.username}")
    private String username;

    @Value("${spring.cassandra.password}")
    private String password;

    @Value("${spring.cassandra.port}")
    private int port;

    @Value("${spring.cassandra.contact-points}")
    private String contactPoints;

    @Value("${spring.cassandra.keyspace-name}")
    private String keySpace;

    @Override
    protected int getPort() {
        return port;
    }

    @Override
    protected String getContactPoints() {
        return contactPoints;
    }

    @Override
    protected String getKeyspaceName() {
        return keySpace;
    }

    @Override
    public SchemaAction getSchemaAction() {
        return SchemaAction.CREATE_IF_NOT_EXISTS;
    }

    @Override
    public String[] getEntityBasePackages() {
        return new String[]{"com.azamakram.github.BookStore.model.entity"};
    }


    @Override
    protected List<CreateKeyspaceSpecification> getKeyspaceCreations() {
        return Collections.singletonList(CreateKeyspaceSpecification
            .createKeyspace(getKeyspaceName())
            .ifNotExists()
            .with(KeyspaceOption.DURABLE_WRITES, true)
            .withSimpleReplication());
    }

    @Bean
    @Override
    public CqlSessionFactoryBean cassandraSession() {
        CqlSessionFactoryBean cassandraSession = super.cassandraSession();//super session should be called only once
        cassandraSession.setKeyspaceCreations(getKeyspaceCreations());
        cassandraSession.setContactPoints(contactPoints);
        cassandraSession.setPort(port);
        cassandraSession.setUsername(username);
        cassandraSession.setPassword(password);
        return cassandraSession;
    }

}

Run Cassandra docker image

We fetch the latest version of cassandra docker image "docker.io/bitnami/cassandra:latest" and define other configurations in the docker-compose.yml file. docker-compose will run a cassandra image in the docker container which bookstore-service will be using.

version: '3.9'

services:
  cassandra:
    image: docker.io/bitnami/cassandra:latest
    ports:
      - '7000:7000'
      - '9042:9042'
    environment:
      - CASSANDRA_PASSWORD_SEEDER=yes
      - CASSANDRA_USERNAME=cassandra
      - CASSANDRA_PASSWORD=cassandra

we run the cassandra instance by,

docker-compose up

Containerize the application

Build application

In the previous step we have run the cassandra image, now it's time to build our application. It will create springboot-cassandra-docker-0.0.1-SNAPSHOT.jar file in build/lib directory,

./gradle build

Create docker image

I will create a docker image bookStore-service for this application - we create a Dockerfile and write what we actually want docker to do.

  • FROM: specifies the base image (providing JDK19) for this new image
  • EXPOSE: tells Docker to allow incoming traffic on port 8080.
  • ADD: copies the file from the build context (the current directory) into the container's filesystem at the path "/app".
  • WORKDIR: sets the working directory for any subsequent command defined in Dockerfile, such as CMD, RUN, COPY etc.
  • CMD: is the command that will be executed when the container is run, here we just run our JAR file
FROM eclipse-temurin:19-jdk-alpine
EXPOSE 8080
ADD build/libs/springboot-cassandra-docker-0.0.1-SNAPSHOT.jar /app/springboot-cassandra-docker.jar
WORKDIR /app/
CMD ["java","-jar","springboot-cassandra-docker.jar"]

now create docker image, I name it "bookstore-service"

docker build -t bookstore-service .

verify if "bookstore-service" is created or not,

docker images

we can also check Docker Desktop application,

The screenshot of all above commands and their output,

Run bookstore-service image

Run bookstore-service image

docker run -p 8080:8080 bookstore-service

Running bookstore-service will connect to cassandra and create keyspace (ks_bookstore) and table (bookstore). We can verify by connecting CQL by command shell,

Note the bookstore-service image id, connect to cql, and write some statements,
Note: I have written inline comment starting by ##

$ docker ps ## returns running image ids
$ docker exec -it <image-id> bash
$ cqlsh -u <user-name> -p <password>
$ describe keyspaces;      ## running the application image should have created key space "ks_bookstore"
$ describe ks_bookstore;
$ use ks_bookstore;
$ describe tables;         ## ks_boostore.bookstore table should also have been created 

Test bookstore-service using Postman

Send POST request to create a Book record,

Now fetch the saved record by GET request

Final Wording

Hope this long text helps you to create and run your spring boot cassandra application and you have some hands on docker containers.

We can automate this process by either adding gradle tasks to create and run application docker image OR we can extend docker-compose to run bookstore-service image.

© 2024 Solution Toolkit . All rights reserved.