Spring Boot Hello World Part 3

post-thumb

Java is still one of the most widely used programming languages in the world, and Spring Boot is the de-facto framework for building production-ready services with it. Surprisingly, even though I’ve worked with Java before, I had never actually touched Spring Boot.

That changes today.

In this series, we’ll build a simple CRUD API for tasks step by step.

In Part 1 we built a simple CRUD API for tasks using Spring Boot and in Part 2 we tested it. In this part, we’ll add Redis to persist our data.

👉 Source for this part: Git repository (part 3 branch).

👉 Pparts:

  • Part 1: Basics — install the tools and build a simple REST API.
  • Part 2: Add unit tests for our endpoints.
  • Part 3 (this post): Store tasks in a real NoSQL database (Redis).

Architecture

LayerComponentResponsibilityNotes
Clientcurl / PostmanCalls REST APISame as before
APITaskControllerExposes endpoints, delegates to DatabaseServiceNo longer manages in-memory list
ServiceDatabaseServiceHandles Redis accessAbstracts persistence from controller
PersistenceRedis (via Spring Data)Stores/retrieves tasksIndexed by UUID, Set for task IDs
Configapplication.ymlRedis connection settingsCan override with env vars REDIS_HOST etc.
Testing (unit)Mocked DatabaseServiceFast controller testsNo Redis needed
Testing (int)Testcontainers RedisFull Redis testsUses Docker, slower but realistic

Installing Tools

For this part we need Docker where we run Redis. I am using Docker instance installed to WSL with APT, but that is too advanced setup for this tutorial, so follow up [https://docs.docker.com/desktop/setup/install/windows-install/](official instructions) to get Docker installed.

Blocking vs. Reactive

Spring Boot offers something called reactive application. Idea of it is that tasks like fetching data from database is asyncronous. Since I chose blocking application in first part, we go with blocking mode. But maybe for practice there will be future part where we do refactoring. There is no practical matter in this case, but with reactive you can have more queries running parallel than in blocking since in blocking mode one query uses up one thread and threads are quite heavy so there is finite number of them.

Why Redis?

I have usually always used SQL databases so for a change and practice I want to go with NoSQL database. And Redis I chose mostly because I am already familiar with by using it as an cache. But generally same princibles works in most NoSQL databases.

Redis Keys we are going to need

tasks              (Set of task IDs)
tasks:<uuid1>      -> { "id": "...", "name": "..." }
tasks:<uuid2>      -> { "id": "...", "name": "..." }

Why Test Containers

Testcontainers ensures every test runs against a clean, isolated Redis instance. No “works on my machine” issues.

Why Spring Profiles

We use Spring application profiles so in future we and extend this application to be run in production.

Implementation

We create docker compose file to run Redis

For Final application, we need Redis running where we can save the data. Create `docker-compose.yml to root of the project:

services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

Note, this is for demonstration only, so we do not configured Redis to actually persist the data.

Note, redis can be started up running:

docker compose up -v

We create application.yml for Redis configuration

Create src/main/resources/application.yml:

spring:
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      database: ${REDIS_DATABASE:0}
      username: ${REDIS_USERNAME:}
      password: ${REDIS_PASSWORD:}

Default values are good to connect Redis from previous chapter, but with REDIS_HOST, etc environment variables can be used to connect any other Redis.

We configure dependencies

Add test dependencies to build.gradle:

dependencies {
  implementation('org.springframework.boot:spring-boot-starter-data-redis')
  testImplementation 'com.redis:testcontainers-redis'
  testImplementation 'org.springframework.boot:spring-boot-testcontainers'
  testImplementation 'org.testcontainers:junit-jupiter'
}

We want Spring Boot Redis runtime and test dependencies. And Additionally we need testcontainers dependencies so we can run Redis in Docker container instead of handling instance manually for tests.

Refresh dependencies with or you get to weird caching problems with dependencies especially if you are not doing it in one go:

gradle build --refresh-dependencies

We create DatabaseService and tests for it (tests uses containerized Redis so we need docker)

DatabaseService is the service we use to access data. Idea is that we can mock this when we run unit tests for controllers. And the DatabaseService itself is tested with integration test.

Create src/main/java/fi/janihast/springboot_hello_world/DatabaseService.java:

package fi.janihast.springboot_hello_world;

import com.fasterxml.jackson.databind.ObjectMapper;
import fi.janihast.springboot_hello_world.models.Task;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class DatabaseService {
    private final ObjectMapper objectMapper;
    private final StringRedisTemplate template;

    public DatabaseService(StringRedisTemplate template) {
        this.template = template;
        this.objectMapper = new ObjectMapper();
    }

    public void saveTask(Task task) throws RuntimeException{
        String json;
        try {
            json = this.objectMapper.writeValueAsString(task);
        } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
            throw new RuntimeException("Failed to serialize Task object", e);
        }
        this.template.opsForValue().set("tasks:" + task.getID(), json);
        this.template.opsForSet().add("tasks", task.getID().toString());
    } 

    public List<Task> loadTasks() throws RuntimeException {
        List<Task> tasks = new ArrayList<>();
        try {
            Set<String> taskIds = this.template.opsForSet().members("tasks");
            if (taskIds != null) {
                for (String taskId : taskIds) {
                    Task task = loadTask(UUID.fromString(taskId));
                    if (task != null) {
                        tasks.add(task);
                    }
                }
            }
        } catch (Exception e) {
            throw new RuntimeException("Failed to load tasks", e);
        }
        return tasks;
    }

    public Task loadTask(UUID id) throws RuntimeException {
        String json = this.template.opsForValue().get("tasks:" + id);
        if (json == null) {
            return null;
        }

        Task task;
        try {
            task = this.objectMapper.readValue(json, Task.class);
        } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
            throw new RuntimeException("Failed to deserialize Task object", e);
        }

        return task;
    }

    public void deleteTask(UUID id) {
        this.template.delete("tasks:" + id);
        this.template.opsForSet().remove("tasks", id.toString());
    }

    public boolean hasTask(UUID id) {
        return this.template.hasKey("tasks:" + id);
    }
}

What is notable here is that we make tasks-index along side so if we need to check task existence, we can do it by using Redis Set.

After that we create configuration for integration tests src/test/java/fi/janihast/springboot_hello_world/RedisTestConfig.java:

package fi.janihast.springboot_hello_world;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import com.redis.testcontainers.RedisContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration
public class RedisTestConfig {

    @Bean
    @ServiceConnection
    public RedisContainer redisContainer() {
        return new RedisContainer(DockerImageName.parse("redis:7-alpine"));
    }
}

Then we need tests them selves:

package fi.janihast.springboot_hello_world;

import fi.janihast.springboot_hello_world.models.Task;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertNotNull;

@Testcontainers
@SpringBootTest
@Import(RedisTestConfig.class)
public class DatabaseServiceTests {

    @Autowired DatabaseService databaseService;

    @Test
    void shouldSaveTaskAndRetrieveIt() {
        UUID id = UUID.fromString("ae9be46e-06dc-44df-861a-29d32ec7affb");

        Task task = new Task();
        task.setID(id);
        task.setName("Hello!");

        this.databaseService.saveTask(task);
        assertEquals("Hello!", this.databaseService.loadTask(id).getName());
    }

    @Test
    void shouldSaveTaskAndDeleteIt() {
        UUID id = UUID.fromString("476e6f2a-bf32-4a4c-8463-7e248f972b41");

        Task task = new Task();
        task.setID(id);
        task.setName("Hello!");

        this.databaseService.saveTask(task);
        assertEquals("Hello!", this.databaseService.loadTask(id).getName());

        this.databaseService.deleteTask(id);
        assertNull(this.databaseService.loadTask(id));
    }

    @Test
    void shouldSaveAndCheckExistence() {
        UUID id = UUID.fromString("d1f3c6e2-2f3a-4f4e-8b1e-5c6d7e8f9a0b");

        Task task = new Task();
        task.setID(id);
        task.setName("Hello!");

        assertEquals(false, this.databaseService.hasTask(id));

        this.databaseService.saveTask(task);
        assertEquals(true, this.databaseService.hasTask(id));

        this.databaseService.deleteTask(id);
        assertEquals(false, this.databaseService.hasTask(id));
    }

    @Test
    void shouldLoadMultipleTasks() {
        UUID id1 = UUID.fromString("945ed9a8-487a-4241-82e7-5487e2b99814");
        UUID id2 = UUID.fromString("3e3795db-c6d2-42ac-aef8-aaba6dcde9e1");

        Task task1 = new Task();
        task1.setID(id1);
        task1.setName("Task 1");

        Task task2 = new Task();
        task2.setID(id2);
        task2.setName("Task 2");

        this.databaseService.saveTask(task1);
        this.databaseService.saveTask(task2);

        List<Task> tasks = this.databaseService.loadTasks();
        Task task1Found = null;
        Task task2Found = null;
        for (Task task : tasks) {
            if (task.getID().equals(id1)) {
                task1Found = task;
            }
            if (task.getID().equals(id2)) {
                task2Found = task;
            }
        }

        assertNotNull(task1Found);
        assertNotNull(task2Found);
    }
}

We refactor TasksController to use DatabaseService

You can see all the endpoints from the source, but here is the first one:

@GetMapping(value = "")
public TaskGetAllResponse getTasks() {
  List<Task> tasks = this.databaseService.loadTasks();
  TaskGetAllResponse response = new TaskGetAllResponse();
  response.setTasks(tasks);
  return response;
}

So what we changed was that we are not using the tasks instances from class anymore, but the command from DatabaseService. Others go same way.

We refactor TasksControllerTests to use mockup DatabaseService

You can see all the tests from source, but here is the first one:

@Test
void shouldRetrieveTasks() throws Exception {
  UUID taskId1 = UUID.fromString("54d7f0d1-7ec9-4c39-a6f2-b88cfeb81c7e");
  UUID taskId2 = UUID.fromString("f9f94724-06f9-436f-ae12-87fd47005e89");

  Task task1 = new Task();
  task1.setID(taskId1);
  task1.setName("First task to be retrieved");

  Task task2 = new Task();
  task2.setID(taskId2);
  task2.setName("Second task to be retrieved");

  when(databaseService.loadTasks()).thenReturn(List.of(task1, task2));

  // Retrieve the list of tasks and check if the created task is present
  this.mockMvc.perform(get("/tasks"))
    .andExpect(status().isOk())
    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
    .andExpect(jsonPath("$.tasks").exists())
    .andExpect(jsonPath("$.tasks[?(@.id=='" + taskId1 + "')].name").exists());

  verify(databaseService).loadTasks();
  verifyNoMoreInteractions(databaseService);
}

So what changed was that we no longer need to worry about what is inside the database, we can mock up the DatabaseService requests so we do not need full blown Redis on running the tests. Decoupling things in tests are always good so you can determine problem faster. This way tests in future runs fast too since integration tests needs Redis starting, stopping and queries also take time.

Verify at the end is mandatory so we know the requests were actually done and that there is no unwanted requests done.

Recap

And now we have fully working persistent storage and that concludes the 3 part series. Thank you for reading. See you next time!

Table for DatabaseService requests:

MethodPurposeBacked by Redis ops
saveTask(Task)Store/update a taskSET tasks:<id> + SADD tasks
loadTask(UUID)Retrieve single taskGET tasks:<id>
loadTasks()Retrieve all tasksSMEMBERS tasks + multiple GET
deleteTask(UUID)Delete taskDEL tasks:<id> + SREM tasks
hasTask(UUID)Check existenceEXISTS tasks:<id>

References

Quick reference

Run integration tests with Redis Testcontainer

gradle test --tests "fi.janihast.springboot_hello_world.DatabaseServiceTests"

Run all tests (unit + integration)

gradle test

Refresh dependencies

gradle build --refresh-dependencies
comments powered by Disqus