Spring Boot Hello World Part 3
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
Layer | Component | Responsibility | Notes |
---|---|---|---|
Client | curl / Postman | Calls REST API | Same as before |
API | TaskController | Exposes endpoints, delegates to DatabaseService | No longer manages in-memory list |
Service | DatabaseService | Handles Redis access | Abstracts persistence from controller |
Persistence | Redis (via Spring Data) | Stores/retrieves tasks | Indexed by UUID, Set for task IDs |
Config | application.yml | Redis connection settings | Can override with env vars REDIS_HOST etc. |
Testing (unit) | Mocked DatabaseService | Fast controller tests | No Redis needed |
Testing (int) | Testcontainers Redis | Full Redis tests | Uses 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:
Method | Purpose | Backed by Redis ops |
---|---|---|
saveTask(Task) | Store/update a task | SET tasks:<id> + SADD tasks |
loadTask(UUID) | Retrieve single task | GET tasks:<id> |
loadTasks() | Retrieve all tasks | SMEMBERS tasks + multiple GET |
deleteTask(UUID) | Delete task | DEL tasks:<id> + SREM tasks |
hasTask(UUID) | Check existence | EXISTS 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