Getting started with Spring Data Redis with Kotlin

kotlin-spriangboot-redis

Redis is one of the most popular and versatile caching tools available. It supports clustering, a very crucial feature for distributed and microservice architectures. Additionally, it has an easy setup process. And thanks to the Spring framework, integrating services written in JVM with Redis is easier than ever. One can use the familiar Spring Data repository to interact with Redis neatly. In this article, we explain how to get started with Spring Data Redis with Kotlin. We provide a simplified real-world example for better understanding.

Before starting the tutorial, it is worth noting that this article is meant for those who intend to use Redis as a cache or in-memory database. However, if you prefer to use Redis as a message broker (pub/sub pattern), check our Redis Pub/Sub with Spring Boot article instead.

Example project

In this section, we draft architectural elements about an imaginary movie service that uses Redis cache as its underlying in-memory database.

Hashes data structure

The imaginary service is a movie rest service that exposes Rest APIs for CRUD operations. Underneath, we use two simple data structures, RedisHash, movie, and actor. Furthermore, there’s a unidirectional one-to-many relationship between movie and actor hashes. One movie can have many actors. For a better demonstration let’s look at the below diagram:

hashes structure

It is important to note that since Redis is not an RDMS database modeling the above diagram is not so straightforward. That means before persisting a movie to Redis must link that to its corresponding actor object manually. Spring Data Redis doesn’t provide a way to achieve that (unlike Hibernate). Hence, we work around the issue by adding a link endpoint. See the API endpoints section for further details.

Furthermore, it’s impossible to search in relation (let’s say get a list of movies played by actor Johnny Depp) by adding a single method to the corresponding Repository. We need to implement it manually. Since it complicates our example, we don’t cover that in this article.

API endpoints

Now that we have our data structure set, we need to have some endpoints to manipulate data stored in Redis. To do that, we listed all endpoints we need in below:

  • Movies
    • List
    • Create
    • Update
    • Delete
  • Actors
    • List
    • Create
    • Update
    • Delete
    • Link*

* As stated earlier, Redis does not persist relations automatically. So to relate an actor to a movie (e.g., Keanu Reeves to John Wick), we need to have an endpoint to work around the limitation. That’s where the link endpoint comes into the picture.

Creating the Maven Kotlin project

The first thing we need to do is to create our project. For the Java Maven project, our preference is to handcraft the project. However, since we use Kotlin and Spring, we highly recommend using start.spring.io to create the project.

It is crucial to select Kotlin as the language and required dependencies as shown in the picture below.

start-spring-io-kotlin

The pom.xml of our example should look like this:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.0</version>
        <relativePath/>
    </parent>
    <groupId>com.madadipouya.redis.springdata.example</groupId>
    <artifactId>redis-springdata</artifactId>
    <version>0.0.2-SNAPSHOT</version>
    <name>redis-springdata</name>
    <description>Spring Data Redis Example</description>
    <properties>
        <java.version>17</java.version>
        <kotlin.version>1.8.21</kotlin.version>
        <testcontainers.version>1.18.1</testcontainers.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>4.4.1</version>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-reflect</artifactId>
        </dependency>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-stdlib-jdk8</artifactId>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.module</groupId>
            <artifactId>jackson-module-kotlin</artifactId>
            <version>2.15.2</version>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.redis.testcontainers</groupId>
            <artifactId>testcontainers-redis-junit</artifactId>
            <version>1.6.4</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
        <testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-plugin</artifactId>
                <configuration>
                    <args>
                        <arg>-Xjsr305=strict</arg>
                    </args>
                    <compilerPlugins>
                        <plugin>spring</plugin>
                    </compilerPlugins>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>org.jetbrains.kotlin</groupId>
                        <artifactId>kotlin-maven-allopen</artifactId>
                        <version>${kotlin.version}</version>
                    </dependency>
                    <dependency>
                        <groupId>org.jetbrains.kotlin</groupId>
                        <artifactId>kotlin-maven-noarg</artifactId>
                        <version>${kotlin.version}</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
</project>

Creating models

Now that the base project is ready, we must create movie and actor models.

package com.madadipouya.redis.springdata.example.model

import org.springframework.data.annotation.Id
import org.springframework.data.annotation.Reference
import org.springframework.data.redis.core.RedisHash
import org.springframework.data.redis.core.index.Indexed

@RedisHash("Movies")
data class Movie(
        @Indexed val name: String,
        val genre: String,
        val year: Int
) {
    @get:Id
    var id: String? = null
    @Indexed @get:Reference var actors: List<Actor> = listOf()
}
package com.madadipouya.redis.springdata.example.model

import org.springframework.data.annotation.Id
import org.springframework.data.redis.core.RedisHash
import org.springframework.data.redis.core.index.Indexed
import java.time.LocalDate

@RedisHash("Actors")
data class Actor(
        @Indexed val firstName: String,
        val lastName: String,
        val birthDate: LocalDate
) {
    @get:Id
    var id: String? = null
}

As you can see, there’s a unidirectional relationship between movie and actor hashes, with having List<Actor in the movie. That allows us to retrieve complete information about movies via the list movie endpoint. We discuss that endpoint in detail later.

We also utilize Kotlin data class to create our models. However, we have explicitly excluded the id field from both the movie and the actor base data classes. The reason is we don’t want to set ids explicitly. Instead, we want Redis to generate them for us. Additionally, to allow Spring Data to deserialize an object, we must allow the id field to be nullable. The same concept applies to the Actor collection in the movie model.

Lastly, to apply any Java method annotation with Kotlin, we must prefix the annotation with @get to be effective. Otherwise, it won’t work.

Repositories

The Redis repository is the same as the Spring JPA Data repository. So all we need to do is to extend CrudRepository and create two repositories. One for the movie and another for the actor.

package com.madadipouya.redis.springdata.example.repository

import com.madadipouya.redis.springdata.example.model.Movie
import org.springframework.data.repository.CrudRepository
import org.springframework.stereotype.Repository

@Repository
interface MovieRepository : CrudRepository<Movie, String>
package com.madadipouya.redis.springdata.example.repository

import com.madadipouya.redis.springdata.example.model.Actor
import org.springframework.data.repository.CrudRepository
import org.springframework.stereotype.Repository

@Repository
interface ActorRepository : CrudRepository<Actor, String>

Implementing the core service classes

The next step is to create services that hold all our business logic. These services are an intermediate layer between our controllers and repositories. Since our example is straightforward, we don’t have much business logic to code.

package com.madadipouya.redis.springdata.example.service.impl

import com.madadipouya.redis.springdata.example.controller.MovieController
import com.madadipouya.redis.springdata.example.model.Movie
import com.madadipouya.redis.springdata.example.repository.ActorRepository
import com.madadipouya.redis.springdata.example.repository.MovieRepository
import com.madadipouya.redis.springdata.example.service.MovieService
import com.madadipouya.redis.springdata.example.service.exception.MovieNotFoundException
import org.springframework.stereotype.Service

@Service
class DefaultMovieService(val movieRepository: MovieRepository) : MovieService {

    override fun getMovie(id: String): Movie = movieRepository.findById(id).orElseThrow {
        MovieNotFoundException("Unable to find movie for $id id")
    }

    override fun getAllMovies(): List<Movie> = movieRepository.findAll().toList()

    override fun updateMovie(id: String, movieDto: MovieController.MovieDto): Movie {
        val movie: Movie = movieRepository.findById(id).orElseThrow { MovieNotFoundException("Unable to find movie for $id id") }
        val updatedMovie = movie.copy(name = movieDto.name.orEmpty(), genre = movieDto.genre.orEmpty(), year = movieDto.year)
        updatedMovie.id = movie.id
        return movieRepository.save(updatedMovie)
    }

    override fun updateMovie(movie: Movie): Movie = movieRepository.save(movie)

    override fun createMovie(movieDto: MovieController.MovieDto): Movie {
        return movieRepository.save(Movie(name = movieDto.name.orEmpty(), genre = movieDto.genre.orEmpty(), year = movieDto.year))
    }

    override fun deleteMovie(id: String) = movieRepository.delete(getMovie(id))
}
package com.madadipouya.redis.springdata.example.service.impl

import com.madadipouya.redis.springdata.example.controller.ActorController
import com.madadipouya.redis.springdata.example.model.Actor
import com.madadipouya.redis.springdata.example.model.Movie
import com.madadipouya.redis.springdata.example.repository.ActorRepository
import com.madadipouya.redis.springdata.example.service.ActorService
import com.madadipouya.redis.springdata.example.service.MovieService
import com.madadipouya.redis.springdata.example.service.exception.MovieNotFoundException
import org.springframework.stereotype.Service
import java.util.*

@Service
class DefaultActorService(val actorRepository: ActorRepository, val movieService: MovieService) : ActorService {

    override fun getActor(id: String) = actorRepository.findById(id).orElseThrow {
        MovieNotFoundException("Unable to find actor for $id id")
    }

    override fun getAllActors(): List<Actor> = actorRepository.findAll().toList()

    override fun updateActor(id: String, actorDto: ActorController.ActorDto): Actor {
        val actor = getActor(id).copy(actorDto.firstName, actorDto.lastName, actorDto.birthDate)
        actor.id = id
        return actorRepository.save(actor)
    }

    override fun createActor(actorDto: ActorController.ActorDto): Actor {
        return actorRepository.save(Actor(actorDto.firstName, actorDto.lastName, actorDto.birthDate))
    }

    override fun deleteActor(id: String) = actorRepository.deleteById(id)

    override fun addActorToMovie(actorId: String, movieId: String): Movie {
        val movie: Movie = movieService.getMovie(movieId)
        val actor: Actor = getActor(actorId)
        (movie.actors as ArrayList).add(actor)
        return movieService.updateMovie(movie)
    }
}

Adding controllers

The last step is to add controllers, a simple task.

package com.madadipouya.redis.springdata.example.controller

import com.madadipouya.redis.springdata.example.model.Movie
import com.madadipouya.redis.springdata.example.service.MovieService
import com.madadipouya.redis.springdata.example.subscription.model.Subscriber
import com.madadipouya.redis.springdata.example.subscription.service.SubscriptionService
import jakarta.validation.Valid
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.PastOrPresent
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType.APPLICATION_STREAM_JSON_VALUE
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/v1/movies")
class MovieController(val movieService: MovieService, val subscriptionService: SubscriptionService) {

    @PostMapping
    @ResponseStatus(HttpStatus.ACCEPTED)
    private fun createMovie(@Valid @RequestBody movie: MovieDto): Movie = movieService.createMovie(movie)

    @GetMapping(value = ["/{id}"])
    @ResponseStatus(HttpStatus.OK)
    private fun getMovieById(@PathVariable id: String): Movie = movieService.getMovie(id)

    @PutMapping(value = ["/{id}"])
    @ResponseStatus(HttpStatus.OK)
    private fun updateMovie(@PathVariable id: String, @Valid @RequestBody movie: MovieDto): Movie = movieService.updateMovie(id, movie)

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    private fun getMovies(): List<Movie> = movieService.getAllMovies()

    @DeleteMapping(value = ["/{id}"])
    @ResponseStatus(HttpStatus.NO_CONTENT)
    private fun deleteMovie(id: String) = movieService.deleteMovie(id)


    @GetMapping(value = ["/subscribe"], produces = [APPLICATION_STREAM_JSON_VALUE])
    private fun subscribeToMovie(): Subscriber = subscriptionService.subscribe(Subscriber())

    data class MovieDto(
        @get:NotBlank(message = "Movie name cannot be empty") val name: String?,
        @get:NotBlank(message = "Movie genre cannot be empty") val genre: String?,
        @get:Min(message = "Movie year should be 1900 or after", value = 1900) @PastOrPresent val year: Int
    )
}
package com.madadipouya.redis.springdata.example.controller

import com.fasterxml.jackson.annotation.JsonFormat
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer
import com.madadipouya.redis.springdata.example.model.Actor
import com.madadipouya.redis.springdata.example.model.Movie
import com.madadipouya.redis.springdata.example.service.ActorService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Past
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.HttpStatus
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.*
import java.time.LocalDate

@RestController
@RequestMapping("/v1/actors")
class ActorController(val actorService: ActorService) {

    @PostMapping
    @ResponseStatus(HttpStatus.ACCEPTED)
    private fun createActor(@Valid @RequestBody actor: ActorDto): Actor = actorService.createActor(actor)

    @GetMapping(value = ["/{id}"])
    @ResponseStatus(HttpStatus.OK)
    private fun getActorById(@PathVariable id: String): Actor = actorService.getActor(id)

    @PutMapping(value = ["/{id}"])
    @ResponseStatus(HttpStatus.OK)
    private fun updateActor(@PathVariable id: String, @Valid @RequestBody actor: ActorDto): Actor = actorService.updateActor(id, actor)

    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    private fun getActors(): List<Actor> = actorService.getAllActors()

    @DeleteMapping(value = ["/{id}"])
    @ResponseStatus(HttpStatus.NO_CONTENT)
    private fun deleteActor(id: String) = actorService.deleteActor(id)

    @PatchMapping(value = ["/{actorId}/link/{movieId}"])
    @ResponseStatus(HttpStatus.OK)
    private fun addActorToMovie(@PathVariable actorId: String, @PathVariable movieId: String): Movie {
        return actorService.addActorToMovie(actorId, movieId)
    }

    data class ActorDto(
        @get:NotBlank(message = "First name cannot be empty") val firstName: String,
        @get:NotBlank(message = "Last name cannot be empty") val lastName: String,
        @field:DateTimeFormat(pattern = "yyyy-MM-dd")
        @field:JsonDeserialize(using = LocalDateDeserializer::class)
        @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
        @get:Past(message = "Provide date in yyyy-MM-dd format in past time") val birthDate: LocalDate
    )
}

We, unfortunately, cannot apply Hibernate or Jakarta validators to our Redis model. Hence, to disallow users from passing unwanted values, we must perform validations only at the DTO level.

Running the application

Now that we have finished everything, we are ready to run the application for the first time. But before that, we need to make sure the Redis is running. If you don’t like to install it in your local you can use the below docker-compose file to run the Redis in the container,

redis:
  image: redis:latest
  ports:
    - "6379:6379"

To run the docker-compose file use the below command,

$ docker-compose -f docker-compose.yml up -d

Then you can run the project with the below command,

$ ./mvnw spring-boot:run

To override the default Redis host address and port, you should override SPRING_REDIS_HOST and SPRING_REDIS_PORT environment variables. Alternatively, you can change application.properties directly.

Conclusion

In this tutorial, we discussed how to get started with Spring Data Redis with Kotlin as an in-memory database. To achieve that, we created an imaginary movie service and discussed some Redis limitations, such as lack of relations. Lastly, we implemented the project in Kotlin. The working example is available on GitHub at the link below:

https://github.com/kasramp/spring-data-redis-example-kotlin

Inline/featured images credits