Chapter Introduction
Welcome to Chapter 11 of our Java project series! In the previous chapters, we established our project structure, set up our development environment with the latest Java version (Java 24/25), and perhaps even created some basic data models. This chapter marks a significant step forward as we introduce the Service Layer – the heart of our application’s business logic.
The service layer is crucial for separating concerns, ensuring that our core application rules, validations, and operations are encapsulated in a distinct, reusable, and testable component. Instead of scattering business logic across various parts of the application (like a user interface or data access layer), we centralize it here. This approach makes our code easier to understand, maintain, and evolve. For our Basic To-Do List Application, the service layer will manage tasks: adding, retrieving, updating, and deleting them, while enforcing any specific rules for these actions.
By the end of this chapter, you will have a fully functional and thoroughly tested service layer for managing To-Do items. This layer will be independent of any user interface or specific persistence mechanism, laying a solid foundation for future chapters where we will integrate a UI and persistent storage.
Planning & Design
To build a robust and maintainable To-Do List application, we’ll adhere to a layered architecture. This chapter focuses on the Service Layer and an abstract Data Access Layer interface, using an in-memory implementation for simplicity in this chapter.
Component Architecture for To-Do List Management
Our architecture will consist of three primary components for managing tasks:
Task(Model Layer): A simple data structure representing a single To-Do item. It holds the state of a task (ID, description, status).TaskRepository(Data Access Layer - Interface): Defines the contract for how our application interacts with task data persistence. It’s an interface, meaning we can swap out different implementations (e.g., in-memory, database, file system) without affecting the service layer.TaskService(Service Layer - Business Logic): Contains the core business rules for managing tasks. It uses aTaskRepositoryto perform data operations and applies validations and logic before and after these operations.
File Structure
We’ll organize our code into distinct packages to reflect this layered architecture:
src/main/java/com/example/todolist/
├── model/
│ └── Task.java
├── repository/
│ ├── TaskRepository.java
│ └── InMemoryTaskRepository.java
└── service/
├── TaskService.java
└── exception/
└── TaskNotFoundException.java
└── InvalidTaskException.java
And for testing:
src/test/java/com/example/todolist/
├── repository/
│ └── InMemoryTaskRepositoryTest.java
└── service/
└── TaskServiceTest.java
Step-by-Step Implementation
We’ll build the To-Do List application’s service layer incrementally.
a) Setup/Configuration
First, ensure your Maven pom.xml (or Gradle build.gradle) has the necessary dependencies for logging and testing. We’ll use SLF4J with Logback for logging, and JUnit 5 with Mockito for testing.
Add Dependencies to pom.xml:
<!-- Core Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.12</version> <!-- Using latest stable SLF4J API -->
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.14</version> <!-- Using latest stable Logback -->
</dependency>
<!-- Testing Dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.1</version> <!-- Latest JUnit 5 -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.10.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.9.0</version> <!-- Latest Mockito core -->
<scope>test</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.9.0</version> <!-- Mockito integration with JUnit 5 -->
<scope>test</scope>
</dependency>
Why these versions? We’re aiming for the latest stable versions as of December 2025 to ensure we benefit from bug fixes, performance improvements, and new features. SLF4J provides a logging facade, allowing us to swap out logging implementations easily, with Logback being a popular and performant choice. JUnit 5 is the standard for unit testing in modern Java, and Mockito is essential for creating test doubles (mocks) to isolate units of code during testing.
Now, let’s create the necessary package structure and initial files.
b) Core Implementation
1. Define the Task Model
We’ll use a Java record for our Task model. Records are a concise way to declare immutable data classes in modern Java (introduced in Java 16), perfect for models.
File: src/main/java/com/example/todolist/model/Task.java
package com.example.todolist.model;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Represents a single To-Do item.
* Using a Java Record for immutability and conciseness.
*
* @param id Unique identifier for the task.
* @param description A brief description of the task.
* @param completed Status of the task (true if completed, false otherwise).
* @param createdAt Timestamp when the task was created.
* @param updatedAt Timestamp when the task was last updated.
*/
public record Task(
UUID id,
String description,
boolean completed,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
/**
* Factory method to create a new, uncompleted task.
* Generates a new UUID, sets creation/update timestamps.
*
* @param description The description for the new task.
* @return A new Task instance.
* @throws IllegalArgumentException if description is null or blank.
*/
public static Task createNew(String description) {
if (description == null || description.isBlank()) {
throw new IllegalArgumentException("Task description cannot be null or blank.");
}
UUID newId = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
return new Task(newId, description.trim(), false, now, now);
}
/**
* Creates a new Task instance with an updated description.
*
* @param newDescription The new description for the task.
* @return A new Task instance with the updated description and updated timestamp.
* @throws IllegalArgumentException if newDescription is null or blank.
*/
public Task withDescription(String newDescription) {
if (newDescription == null || newDescription.isBlank()) {
throw new IllegalArgumentException("Task description cannot be null or blank.");
}
return new Task(this.id, newDescription.trim(), this.completed, this.createdAt, LocalDateTime.now());
}
/**
* Creates a new Task instance with an updated completion status.
*
* @param newCompleted The new completion status for the task.
* @return A new Task instance with the updated completion status and updated timestamp.
*/
public Task withCompleted(boolean newCompleted) {
return new Task(this.id, this.description, newCompleted, this.createdAt, LocalDateTime.now());
}
}
Explanation:
- We use
java.util.UUIDfor robust, globally unique task identifiers. LocalDateTimeprovides precise timestamps.- The
createNewstatic factory method simplifies creating new tasks, automatically generating an ID and timestamps, and performing basic validation. withDescriptionandwithCompletedmethods demonstrate the power of records for “modifying” immutable objects by returning a new instance with the desired change. This is a common pattern for immutable data structures.- Basic input validation (
IllegalArgumentException) is included right in the model.
2. Define the TaskRepository Interface
This interface defines the contract for how our service layer will interact with task data.
File: src/main/java/com/example/todolist/repository/TaskRepository.java
package com.example.todolist.repository;
import com.example.todolist.model.Task;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Defines the contract for data access operations related to Task entities.
* This interface allows for different persistence implementations (e.g., in-memory, database).
*/
public interface TaskRepository {
/**
* Saves a task to the repository. If the task already exists (based on ID), it should be updated.
* Otherwise, it's added as a new task.
*
* @param task The task to save.
* @return The saved task.
* @throws NullPointerException if the task is null.
*/
Task save(Task task);
/**
* Finds a task by its unique identifier.
*
* @param id The UUID of the task to find.
* @return An Optional containing the task if found, or an empty Optional otherwise.
* @throws NullPointerException if the id is null.
*/
Optional<Task> findById(UUID id);
/**
* Retrieves all tasks currently stored in the repository.
*
* @return A list of all tasks. Returns an empty list if no tasks are present.
*/
List<Task> findAll();
/**
* Deletes a task by its unique identifier.
*
* @param id The UUID of the task to delete.
* @return true if the task was found and deleted, false otherwise.
* @throws NullPointerException if the id is null.
*/
boolean deleteById(UUID id);
}
Explanation:
Optional<Task>is used forfindByIdto explicitly handle cases where a task might not be found, preventingNullPointerExceptions and making the API clearer.- The contract is simple and focused on CRUD (Create, Read, Update, Delete) operations.
3. Implement InMemoryTaskRepository
For this chapter, we’ll use a simple in-memory implementation of our TaskRepository. This allows us to test the service layer without needing a database.
File: src/main/java/com/example/todolist/repository/InMemoryTaskRepository.java
package com.example.todolist.repository;
import com.example.todolist.model.Task;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* In-memory implementation of the TaskRepository interface.
* Uses a ConcurrentHashMap for thread-safe storage of tasks.
* This implementation is suitable for development and testing, but not for production
* where data persistence beyond application lifetime is required.
*/
public class InMemoryTaskRepository implements TaskRepository {
private static final Logger log = LoggerFactory.getLogger(InMemoryTaskRepository.class);
private final ConcurrentMap<UUID, Task> tasks = new ConcurrentHashMap<>();
@Override
public Task save(Task task) {
if (task == null) {
log.error("Attempted to save a null task.");
throw new NullPointerException("Task cannot be null.");
}
tasks.put(task.id(), task);
log.info("Task saved: {}", task.id());
return task;
}
@Override
public Optional<Task> findById(UUID id) {
if (id == null) {
log.error("Attempted to find task with null ID.");
throw new NullPointerException("Task ID cannot be null.");
}
return Optional.ofNullable(tasks.get(id));
}
@Override
public List<Task> findAll() {
return new ArrayList<>(tasks.values());
}
@Override
public boolean deleteById(UUID id) {
if (id == null) {
log.error("Attempted to delete task with null ID.");
throw new NullPointerException("Task ID cannot be null.");
}
Task removedTask = tasks.remove(id);
if (removedTask != null) {
log.info("Task deleted: {}", id);
return true;
}
log.warn("Attempted to delete non-existent task: {}", id);
return false;
}
}
Explanation:
ConcurrentHashMapis used to ensure thread-safety, which is a good practice even for simple in-memory storage, anticipating future multi-threaded access.Logger(org.slf4j.Logger) is injected and used for logging operations, providing visibility into what the repository is doing.NullPointerExceptionchecks are added for input parameters (task,id) to prevent unexpected behavior and provide clear error messages.Optional.ofNullablecorrectly handles cases wheretasks.get(id)might returnnull.
4. Define Custom Exceptions
For robust error handling, we’ll define specific exceptions for our service layer.
File: src/main/java/com/example/todolist/service/exception/TaskNotFoundException.java
package com.example.todolist.service.exception;
import java.util.UUID;
/**
* Custom exception indicating that a requested task could not be found.
*/
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(UUID taskId) {
super("Task with ID " + taskId + " not found.");
}
}
File: src/main/java/com/example/todolist/service/exception/InvalidTaskException.java
package com.example.todolist.service.exception;
/**
* Custom exception indicating that a task operation was attempted with invalid data.
*/
public class InvalidTaskException extends RuntimeException {
public InvalidTaskException(String message) {
super(message);
}
public InvalidTaskException(String message, Throwable cause) {
super(message, cause);
}
}
Explanation:
- These are
RuntimeExceptions, meaning they don’t need to be explicitly declared in method signatures (throwsclause). This is often preferred in modern applications for common business exceptions, as it avoids cluttering code with excessivetry-catchblocks for recoverable business errors. - They provide specific context, making debugging and error handling clearer than generic exceptions.
5. Implement TaskService (Business Logic)
This is the core component where business rules are applied. It orchestrates operations using the TaskRepository.
File: src/main/java/com/example/todolist/service/TaskService.java
package com.example.todolist.service;
import com.example.todolist.model.Task;
import com.example.todolist.repository.TaskRepository;
import com.example.todolist.service.exception.InvalidTaskException;
import com.example.todolist.service.exception.TaskNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service layer for managing To-Do tasks.
* Encapsulates business logic, validation, and orchestrates data access operations.
*/
public class TaskService {
private static final Logger log = LoggerFactory.getLogger(TaskService.class);
private final TaskRepository taskRepository;
/**
* Constructs a TaskService with a given TaskRepository.
* This demonstrates dependency injection, making the service testable and flexible.
*
* @param taskRepository The repository to use for task data operations.
*/
public TaskService(TaskRepository taskRepository) {
this.taskRepository = Objects.requireNonNull(taskRepository, "TaskRepository cannot be null.");
log.info("TaskService initialized with repository: {}", taskRepository.getClass().getSimpleName());
}
/**
* Adds a new task to the system.
*
* @param description The description of the new task.
* @return The newly created Task.
* @throws InvalidTaskException if the description is null or blank.
*/
public Task addTask(String description) {
try {
Task newTask = Task.createNew(description); // Validation handled by Task record
Task savedTask = taskRepository.save(newTask);
log.info("New task added: {}", savedTask.id());
return savedTask;
} catch (IllegalArgumentException e) {
log.error("Failed to add task due to invalid description: {}", description, e);
throw new InvalidTaskException("Task description cannot be empty.", e);
} catch (Exception e) {
log.error("An unexpected error occurred while adding task with description: {}", description, e);
throw new RuntimeException("Failed to add task.", e);
}
}
/**
* Retrieves a task by its ID.
*
* @param taskId The ID of the task to retrieve.
* @return The found Task.
* @throws TaskNotFoundException if no task with the given ID exists.
* @throws InvalidTaskException if the taskId is null.
*/
public Task getTaskById(UUID taskId) {
if (taskId == null) {
log.error("Attempted to retrieve task with null ID.");
throw new InvalidTaskException("Task ID cannot be null.");
}
return taskRepository.findById(taskId)
.orElseThrow(() -> {
log.warn("Task with ID {} not found.", taskId);
return new TaskNotFoundException(taskId);
});
}
/**
* Retrieves all tasks in the system.
*
* @return A list of all tasks. Returns an empty list if no tasks are present.
*/
public List<Task> getAllTasks() {
List<Task> tasks = taskRepository.findAll();
log.debug("Retrieved {} tasks.", tasks.size());
return tasks;
}
/**
* Updates the description of an existing task.
*
* @param taskId The ID of the task to update.
* @param newDescription The new description for the task.
* @return The updated Task.
* @throws TaskNotFoundException if the task does not exist.
* @throws InvalidTaskException if taskId is null or newDescription is null/blank.
*/
public Task updateTaskDescription(UUID taskId, String newDescription) {
if (taskId == null) {
log.error("Attempted to update task description with null ID.");
throw new InvalidTaskException("Task ID cannot be null.");
}
if (newDescription == null || newDescription.isBlank()) {
log.error("Attempted to update task description for {} with null or blank description.", taskId);
throw new InvalidTaskException("New task description cannot be null or blank.");
}
Task existingTask = getTaskById(taskId); // Reuses getTaskById for existence check
Task updatedTask = existingTask.withDescription(newDescription);
taskRepository.save(updatedTask); // Save handles update
log.info("Task {} description updated to: {}", taskId, newDescription);
return updatedTask;
}
/**
* Updates the completion status of an existing task.
*
* @param taskId The ID of the task to update.
* @param completed The new completion status (true for completed, false for not completed).
* @return The updated Task.
* @throws TaskNotFoundException if the task does not exist.
* @throws InvalidTaskException if taskId is null.
*/
public Task updateTaskStatus(UUID taskId, boolean completed) {
if (taskId == null) {
log.error("Attempted to update task status with null ID.");
throw new InvalidTaskException("Task ID cannot be null.");
}
Task existingTask = getTaskById(taskId);
Task updatedTask = existingTask.withCompleted(completed);
taskRepository.save(updatedTask);
log.info("Task {} completion status updated to: {}", taskId, completed);
return updatedTask;
}
/**
* Deletes a task by its ID.
*
* @param taskId The ID of the task to delete.
* @throws TaskNotFoundException if the task does not exist.
* @throws InvalidTaskException if taskId is null.
*/
public void deleteTask(UUID taskId) {
if (taskId == null) {
log.error("Attempted to delete task with null ID.");
throw new InvalidTaskException("Task ID cannot be null.");
}
if (!taskRepository.deleteById(taskId)) {
log.warn("Attempted to delete non-existent task: {}", taskId);
throw new TaskNotFoundException(taskId);
}
log.info("Task {} deleted successfully.", taskId);
}
}
Explanation:
- Dependency Injection: The
TaskRepositoryis injected via the constructor (public TaskService(TaskRepository taskRepository)). This is a fundamental best practice for testability and loose coupling. - Logging:
log.info,log.warn,log.error,log.debugare used extensively to provide insights into the service’s operations, crucial for monitoring and debugging in production. - Error Handling:
Objects.requireNonNullensures the injected repository is not null.try-catchblocks handle potentialIllegalArgumentExceptionfrom theTaskmodel, converting it to a more specificInvalidTaskException.orElseThrowwith a lambda is used when retrieving tasks to throwTaskNotFoundExceptionif theOptionalis empty.- Explicit
nullchecks fortaskIdanddescriptionparameters lead toInvalidTaskException.
- Business Logic:
addTask: Creates a new task and saves it.getTaskById: Retrieves a task, throwingTaskNotFoundExceptionif not found.getAllTasks: Returns all tasks.updateTaskDescription,updateTaskStatus: Retrieve the existing task, create a newTaskrecord with updated fields (due to immutability), and then save this new record. The repository’ssavemethod handles whether it’s an insert or an update based on the ID.deleteTask: Deletes a task, throwingTaskNotFoundExceptionif the task to be deleted doesn’t exist.
c) Testing This Component
Now, let’s write unit tests for our InMemoryTaskRepository and TaskService. This is critical to ensure our business logic works as expected.
1. Test InMemoryTaskRepository
File: src/test/java/com/example/todolist/repository/InMemoryTaskRepositoryTest.java
package com.example.todolist.repository;
import com.example.todolist.model.Task;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("InMemoryTaskRepository Tests")
class InMemoryTaskRepositoryTest {
private InMemoryTaskRepository repository;
@BeforeEach
void setUp() {
// Initialize a new repository for each test to ensure isolation
repository = new InMemoryTaskRepository();
}
@Test
@DisplayName("Should save a new task correctly")
void save_newTask_shouldReturnSavedTask() {
Task task = Task.createNew("Buy groceries");
Task savedTask = repository.save(task);
assertNotNull(savedTask);
assertEquals(task.id(), savedTask.id());
assertEquals("Buy groceries", savedTask.description());
assertFalse(savedTask.completed());
assertTrue(repository.findById(savedTask.id()).isPresent());
}
@Test
@DisplayName("Should update an existing task correctly")
void save_existingTask_shouldUpdateTask() {
Task task = Task.createNew("Original description");
repository.save(task);
Task updatedTask = task.withDescription("Updated description").withCompleted(true);
Task savedTask = repository.save(updatedTask);
assertNotNull(savedTask);
assertEquals(task.id(), savedTask.id());
assertEquals("Updated description", savedTask.description());
assertTrue(savedTask.completed());
assertEquals(1, repository.findAll().size()); // Ensure no new task was added
}
@Test
@DisplayName("Should find a task by ID")
void findById_existingId_shouldReturnTask() {
Task task = Task.createNew("Find me");
repository.save(task);
Optional<Task> foundTask = repository.findById(task.id());
assertTrue(foundTask.isPresent());
assertEquals(task.id(), foundTask.get().id());
}
@Test
@DisplayName("Should return empty Optional if task not found by ID")
void findById_nonExistingId_shouldReturnEmptyOptional() {
Optional<Task> foundTask = repository.findById(UUID.randomUUID());
assertTrue(foundTask.isEmpty());
}
@Test
@DisplayName("Should return all tasks")
void findAll_shouldReturnAllTasks() {
repository.save(Task.createNew("Task 1"));
repository.save(Task.createNew("Task 2"));
List<Task> tasks = repository.findAll();
assertEquals(2, tasks.size());
}
@Test
@DisplayName("Should delete a task by ID")
void deleteById_existingId_shouldReturnTrueAndRemoveTask() {
Task task = Task.createNew("Delete me");
repository.save(task);
boolean deleted = repository.deleteById(task.id());
assertTrue(deleted);
assertTrue(repository.findById(task.id()).isEmpty());
}
@Test
@DisplayName("Should return false if deleting non-existent task")
void deleteById_nonExistingId_shouldReturnFalse() {
boolean deleted = repository.deleteById(UUID.randomUUID());
assertFalse(deleted);
}
@Test
@DisplayName("Should throw NullPointerException when saving null task")
void save_nullTask_shouldThrowNullPointerException() {
assertThrows(NullPointerException.class, () -> repository.save(null));
}
@Test
@DisplayName("Should throw NullPointerException when finding by null ID")
void findById_nullId_shouldThrowNullPointerException() {
assertThrows(NullPointerException.class, () -> repository.findById(null));
}
@Test
@DisplayName("Should throw NullPointerException when deleting by null ID")
void deleteById_nullId_shouldThrowNullPointerException() {
assertThrows(NullPointerException.class, () -> repository.deleteById(null));
}
}
Explanation:
@BeforeEachensures a freshInMemoryTaskRepositoryinstance for each test, preventing test interference.@DisplayNamemakes test reports more readable.- Assertions like
assertNotNull,assertEquals,assertTrue,assertFalse,assertThrowsare used to verify behavior. - We test both successful operations and error conditions (e.g., passing null IDs).
2. Test TaskService
Now, let’s write unit tests for TaskService. Here, we’ll use Mockito to mock the TaskRepository, ensuring that our TaskService tests only focus on the service’s logic, not the repository’s.
File: src/test/java/com/example/todolist/service/TaskServiceTest.java
package com.example.todolist.service;
import com.example.todolist.model.Task;
import com.example.todolist.repository.TaskRepository;
import com.example.todolist.service.exception.InvalidTaskException;
import com.example.todolist.service.exception.TaskNotFoundException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // Enables Mockito annotations for JUnit 5
@DisplayName("TaskService Tests")
class TaskServiceTest {
@Mock // Mock the TaskRepository dependency
private TaskRepository taskRepository;
@InjectMocks // Inject the mocked taskRepository into taskService
private TaskService taskService;
private Task sampleTask;
private UUID sampleTaskId;
@BeforeEach
void setUp() {
sampleTaskId = UUID.randomUUID();
sampleTask = new Task(sampleTaskId, "Sample Task", false, LocalDateTime.now().minusDays(1), LocalDateTime.now().minusHours(1));
}
@Test
@DisplayName("Should add a new task successfully")
void addTask_validDescription_shouldReturnNewTask() {
String description = "New To-Do Item";
// Mock the behavior of taskRepository.save()
when(taskRepository.save(any(Task.class))).thenAnswer(invocation -> {
Task task = invocation.getArgument(0);
// Simulate saving by returning the task passed in, ensuring it has an ID
assertNotNull(task.id());
return task;
});
Task result = taskService.addTask(description);
assertNotNull(result);
assertEquals(description, result.description());
assertFalse(result.completed());
// Verify that save was called exactly once with any Task object
verify(taskRepository, times(1)).save(any(Task.class));
}
@Test
@DisplayName("Should throw InvalidTaskException when adding task with blank description")
void addTask_blankDescription_shouldThrowInvalidTaskException() {
String description = " ";
assertThrows(InvalidTaskException.class, () -> taskService.addTask(description));
// Verify that save was never called
verify(taskRepository, never()).save(any(Task.class));
}
@Test
@DisplayName("Should retrieve a task by ID successfully")
void getTaskById_existingId_shouldReturnTask() {
when(taskRepository.findById(sampleTaskId)).thenReturn(Optional.of(sampleTask));
Task result = taskService.getTaskById(sampleTaskId);
assertNotNull(result);
assertEquals(sampleTaskId, result.id());
verify(taskRepository, times(1)).findById(sampleTaskId);
}
@Test
@DisplayName("Should throw TaskNotFoundException when retrieving non-existent task by ID")
void getTaskById_nonExistingId_shouldThrowTaskNotFoundException() {
UUID nonExistentId = UUID.randomUUID();
when(taskRepository.findById(nonExistentId)).thenReturn(Optional.empty());
assertThrows(TaskNotFoundException.class, () -> taskService.getTaskById(nonExistentId));
verify(taskRepository, times(1)).findById(nonExistentId);
}
@Test
@DisplayName("Should throw InvalidTaskException when retrieving task with null ID")
void getTaskById_nullId_shouldThrowInvalidTaskException() {
assertThrows(InvalidTaskException.class, () -> taskService.getTaskById(null));
verify(taskRepository, never()).findById(any(UUID.class));
}
@Test
@DisplayName("Should retrieve all tasks successfully")
void getAllTasks_shouldReturnListOfTasks() {
Task task2 = Task.createNew("Another task");
List<Task> tasks = Arrays.asList(sampleTask, task2);
when(taskRepository.findAll()).thenReturn(tasks);
List<Task> result = taskService.getAllTasks();
assertNotNull(result);
assertEquals(2, result.size());
assertTrue(result.contains(sampleTask));
assertTrue(result.contains(task2));
verify(taskRepository, times(1)).findAll();
}
@Test
@DisplayName("Should update task description successfully")
void updateTaskDescription_validData_shouldReturnUpdatedTask() {
String newDescription = "Updated Description";
Task updatedTask = sampleTask.withDescription(newDescription);
when(taskRepository.findById(sampleTaskId)).thenReturn(Optional.of(sampleTask));
when(taskRepository.save(any(Task.class))).thenReturn(updatedTask); // Simulate saving the new task record
Task result = taskService.updateTaskDescription(sampleTaskId, newDescription);
assertNotNull(result);
assertEquals(sampleTaskId, result.id());
assertEquals(newDescription, result.description());
// Verify findById was called once
verify(taskRepository, times(1)).findById(sampleTaskId);
// Verify save was called once with a Task that has the new description
verify(taskRepository, times(1)).save(argThat(task ->
task.id().equals(sampleTaskId) && task.description().equals(newDescription)
));
}
@Test
@DisplayName("Should throw TaskNotFoundException when updating description of non-existent task")
void updateTaskDescription_nonExistentId_shouldThrowTaskNotFoundException() {
UUID nonExistentId = UUID.randomUUID();
when(taskRepository.findById(nonExistentId)).thenReturn(Optional.empty());
assertThrows(TaskNotFoundException.class, () -> taskService.updateTaskDescription(nonExistentId, "New Desc"));
verify(taskRepository, times(1)).findById(nonExistentId);
verify(taskRepository, never()).save(any(Task.class));
}
@Test
@DisplayName("Should throw InvalidTaskException when updating description with null ID")
void updateTaskDescription_nullId_shouldThrowInvalidTaskException() {
assertThrows(InvalidTaskException.class, () -> taskService.updateTaskDescription(null, "New Desc"));
verify(taskRepository, never()).findById(any(UUID.class));
verify(taskRepository, never()).save(any(Task.class));
}
@Test
@DisplayName("Should throw InvalidTaskException when updating description with blank new description")
void updateTaskDescription_blankDescription_shouldThrowInvalidTaskException() {
when(taskRepository.findById(sampleTaskId)).thenReturn(Optional.of(sampleTask)); // Still needs to find task
assertThrows(InvalidTaskException.class, () -> taskService.updateTaskDescription(sampleTaskId, " "));
verify(taskRepository, times(1)).findById(sampleTaskId); // FindById should still be called
verify(taskRepository, never()).save(any(Task.class));
}
@Test
@DisplayName("Should update task status successfully to completed")
void updateTaskStatus_toCompleted_shouldReturnUpdatedTask() {
Task completedTask = sampleTask.withCompleted(true);
when(taskRepository.findById(sampleTaskId)).thenReturn(Optional.of(sampleTask));
when(taskRepository.save(any(Task.class))).thenReturn(completedTask);
Task result = taskService.updateTaskStatus(sampleTaskId, true);
assertNotNull(result);
assertEquals(sampleTaskId, result.id());
assertTrue(result.completed());
verify(taskRepository, times(1)).findById(sampleTaskId);
verify(taskRepository, times(1)).save(argThat(task ->
task.id().equals(sampleTaskId) && task.completed()
));
}
@Test
@DisplayName("Should update task status successfully to not completed")
void updateTaskStatus_toNotCompleted_shouldReturnUpdatedTask() {
// First, make the sample task completed
sampleTask = sampleTask.withCompleted(true);
Task uncompletedTask = sampleTask.withCompleted(false);
when(taskRepository.findById(sampleTaskId)).thenReturn(Optional.of(sampleTask));
when(taskRepository.save(any(Task.class))).thenReturn(uncompletedTask);
Task result = taskService.updateTaskStatus(sampleTaskId, false);
assertNotNull(result);
assertEquals(sampleTaskId, result.id());
assertFalse(result.completed());
verify(taskRepository, times(1)).findById(sampleTaskId);
verify(taskRepository, times(1)).save(argThat(task ->
task.id().equals(sampleTaskId) && !task.completed()
));
}
@Test
@DisplayName("Should throw TaskNotFoundException when updating status of non-existent task")
void updateTaskStatus_nonExistentId_shouldThrowTaskNotFoundException() {
UUID nonExistentId = UUID.randomUUID();
when(taskRepository.findById(nonExistentId)).thenReturn(Optional.empty());
assertThrows(TaskNotFoundException.class, () -> taskService.updateTaskStatus(nonExistentId, true));
verify(taskRepository, times(1)).findById(nonExistentId);
verify(taskRepository, never()).save(any(Task.class));
}
@Test
@DisplayName("Should throw InvalidTaskException when updating status with null ID")
void updateTaskStatus_nullId_shouldThrowInvalidTaskException() {
assertThrows(InvalidTaskException.class, () -> taskService.updateTaskStatus(null, true));
verify(taskRepository, never()).findById(any(UUID.class));
verify(taskRepository, never()).save(any(Task.class));
}
@Test
@DisplayName("Should delete a task successfully")
void deleteTask_existingId_shouldCompleteSuccessfully() {
when(taskRepository.deleteById(sampleTaskId)).thenReturn(true);
assertDoesNotThrow(() -> taskService.deleteTask(sampleTaskId));
verify(taskRepository, times(1)).deleteById(sampleTaskId);
}
@Test
@DisplayName("Should throw TaskNotFoundException when deleting non-existent task")
void deleteTask_nonExistentId_shouldThrowTaskNotFoundException() {
UUID nonExistentId = UUID.randomUUID();
when(taskRepository.deleteById(nonExistentId)).thenReturn(false);
assertThrows(TaskNotFoundException.class, () -> taskService.deleteTask(nonExistentId));
verify(taskRepository, times(1)).deleteById(nonExistentId);
}
@Test
@DisplayName("Should throw InvalidTaskException when deleting task with null ID")
void deleteTask_nullId_shouldThrowInvalidTaskException() {
assertThrows(InvalidTaskException.class, () -> taskService.deleteTask(null));
verify(taskRepository, never()).deleteById(any(UUID.class));
}
@Test
@DisplayName("Should throw NullPointerException if TaskRepository is null during construction")
void constructor_nullRepository_shouldThrowNullPointerException() {
assertThrows(NullPointerException.class, () -> new TaskService(null));
}
}
Explanation:
@ExtendWith(MockitoExtension.class)integrates Mockito with JUnit 5.@Mockcreates a mock instance ofTaskRepository.@InjectMockscreates an instance ofTaskServiceand injects the@MocktaskRepositoryinto its constructor.when(taskRepository.findById(sampleTaskId)).thenReturn(Optional.of(sampleTask));sets up the expected behavior of the mock repository. WhenfindByIdis called withsampleTaskId, it will return anOptionalcontainingsampleTask.verify(taskRepository, times(1)).save(any(Task.class));asserts that thesavemethod on the mock repository was called exactly once with anyTaskobject.never()is used to assert that a method was not called.argThatis used to verify arguments passed to mock methods with more complex conditions.- We test all public methods of
TaskService, covering both success and various error scenarios, ensuring our custom exceptions are thrown correctly.
Production Considerations
Building a production-ready service layer involves more than just writing code.
Error Handling:
- Custom Exceptions: We’ve implemented
TaskNotFoundExceptionandInvalidTaskException. This provides clear, domain-specific error information that can be translated into appropriate HTTP status codes in a web API or user-friendly messages in a GUI. - Global Exception Handling: In a larger application (e.g., Spring Boot), you’d implement a global exception handler (e.g.,
@ControllerAdvice) to catch these exceptions and return consistent error responses. - Logging Errors: Our service layer logs errors and warnings. This is critical for monitoring and debugging issues in production.
- Custom Exceptions: We’ve implemented
Performance Optimization:
- Repository Choice: Our
InMemoryTaskRepositoryis fast for development but not suitable for production. For production, you’d replace it with a database-backed repository (e.g., using JPA with Hibernate for a relational database, or a NoSQL driver). - Data Structures:
ConcurrentHashMapfor the in-memory repository is a good choice for thread-safe concurrent access. - Future Caching: For frequently accessed data, a caching layer (e.g., Redis, Caffeine) could be introduced between the service and repository. This would be managed by the service layer or a dedicated caching aspect.
- Repository Choice: Our
Security Considerations:
- Input Validation: We’ve added basic validation (e.g., non-null/non-blank descriptions). In a production system, this would be more extensive, covering length constraints, character sets, and business-specific rules. Validation should happen as early as possible (e.g., at the API boundary, then again in the service layer).
- Access Control: For a real application, the service layer would interact with an authentication and authorization system to ensure that only authorized users can perform specific actions (e.g., only the task owner can delete a task). This typically involves passing user context from the presentation layer to the service.
Logging and Monitoring:
- Structured Logging: Using SLF4J and Logback, we’ve implemented basic logging. For production, consider structured logging (e.g., JSON format) which is easier for log aggregators (Elasticsearch, Splunk) to parse and analyze.
- Log Levels: Use appropriate log levels (
DEBUG,INFO,WARN,ERROR).INFOfor normal operations,WARNfor unusual but recoverable situations,ERRORfor critical failures. - Tracing/Correlation IDs: In distributed systems, add correlation IDs to logs across different services to trace a single request’s journey.
Code Review Checkpoint
At this point, you have successfully built a robust and well-tested service layer for your To-Do List application.
Summary of what was built:
TaskRecord: An immutable data model for our To-Do items, including factory and update methods.TaskRepositoryInterface: A contract for data access operations, promoting loose coupling.InMemoryTaskRepository: A simple, thread-safe in-memory implementation of the repository for development and testing.- Custom Exceptions:
TaskNotFoundExceptionandInvalidTaskExceptionfor specific business error handling. TaskService: The core business logic component, responsible for orchestrating task operations, applying validations, and handling errors.- Unit Tests: Comprehensive unit tests for both
InMemoryTaskRepositoryandTaskServiceusing JUnit 5 and Mockito, ensuring correctness and robustness.
Files created/modified:
pom.xml(added logging and testing dependencies)src/main/java/com/example/todolist/model/Task.javasrc/main/java/com/example/todolist/repository/TaskRepository.javasrc/main/java/com/example/todolist/repository/InMemoryTaskRepository.javasrc/main/java/com/example/todolist/service/TaskService.javasrc/main/java/com/example/todolist/service/exception/TaskNotFoundException.javasrc/main/java/com/example/todolist/service/exception/InvalidTaskException.javasrc/test/java/com/example/todolist/repository/InMemoryTaskRepositoryTest.javasrc/test/java/com/example/todolist/service/TaskServiceTest.java
How it integrates with existing code:
The service layer now sits atop the model and repository interfaces. It is completely decoupled from any UI or specific persistence technology. This modularity means we can easily swap out the InMemoryTaskRepository for a database-backed one in a later chapter without altering the TaskService itself.
Common Issues & Solutions
NullPointerExceptionwhen calling service methods:- Error: You might get a
NullPointerExceptionif you forget to instantiateTaskServicewith aTaskRepository, or if you passnullto methods that expect a non-null argument (likeaddTask(null)). - Debugging: Check the stack trace. If it points to
TaskServiceconstructor, ensure you’re passing a validTaskRepositoryinstance. If it points to a method call, ensure you’re not passingnullwhere it’s not allowed. Our code includesObjects.requireNonNulland explicitnullchecks to catch these early. - Prevention: Always initialize dependencies correctly. Use constructor injection (as we did) to make dependencies explicit. Use IDE warnings for potential null dereferences.
- Error: You might get a
TaskNotFoundExceptionorInvalidTaskExceptionunexpectedly:- Error: Your code might throw these custom exceptions when you expect a task to be found or input to be valid.
- Debugging:
- For
TaskNotFoundException: Double-check theUUIDyou’re using. Is it actually present in theInMemoryTaskRepository? Are you trying to retrieve a task that was never added or was already deleted? - For
InvalidTaskException: Review the input parameters (e.g., description string). Is it truly blank ornull? The exception message itself should provide clues. - Examine the logs. Our service layer logs warnings before throwing
TaskNotFoundExceptionand errors forInvalidTaskException, which can help pinpoint the exact condition.
- For
- Prevention: Ensure your client code (or manual test code) provides valid inputs and valid task IDs. Use the
Task.createNew()method for safe task creation.
Tests failing due to state leakage:
- Error: One test passes, but another fails when run together, often in the repository tests.
- Debugging: This typically happens in in-memory repositories if the state (e.g.,
ConcurrentHashMap) is not reset between tests. - Prevention: Our
InMemoryTaskRepositoryTestuses@BeforeEachto create a newInMemoryTaskRepositoryinstance for every single test method. This ensures each test starts with a clean slate, making tests independent and reliable.
Testing & Verification
To verify that your service layer is working correctly, you can run the unit tests we’ve created.
- Open your terminal or IDE.
- Navigate to your project’s root directory.
- Run Maven tests:or for Gradle:
mvn clean testgradle clean test
What should work now:
- All unit tests in
InMemoryTaskRepositoryTestandTaskServiceTestshould pass successfully. This verifies that:- Tasks can be created, retrieved, updated, and deleted.
- Edge cases like invalid inputs or non-existent tasks are handled gracefully with appropriate exceptions.
- The service layer correctly interacts with the repository interface.
- Logging statements are triggered at various points in the service.
How to verify everything is correct:
- Check Test Results: The output from
mvn clean testorgradle clean testshould indicate “BUILD SUCCESS” and show that all tests passed. - Review Logs (Optional Manual Test): You can also create a simple
mainmethod in anApp.javafile to instantiateInMemoryTaskRepositoryandTaskService, then perform some operations. Observe the console output for the log messages.
File: src/main/java/com/example/todolist/App.java (for manual verification)
package com.example.todolist;
import com.example.todolist.model.Task;
import com.example.todolist.repository.InMemoryTaskRepository;
import com.example.todolist.repository.TaskRepository;
import com.example.todolist.service.TaskService;
import com.example.todolist.service.exception.InvalidTaskException;
import com.example.todolist.service.exception.TaskNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.UUID;
public class App {
private static final Logger log = LoggerFactory.getLogger(App.class);
public static void main(String[] args) {
log.info("Starting To-Do List Application demonstration...");
// 1. Initialize Repository and Service
TaskRepository taskRepository = new InMemoryTaskRepository();
TaskService taskService = new TaskService(taskRepository);
// 2. Add some tasks
log.info("\n--- Adding Tasks ---");
Task task1 = taskService.addTask("Learn Java 25 features");
Task task2 = taskService.addTask("Prepare Chapter 12 content");
Task task3 = taskService.addTask("Go for a run");
log.info("Added: {}", task1);
log.info("Added: {}", task2);
log.info("Added: {}", task3);
// Try adding an invalid task
try {
taskService.addTask("");
} catch (InvalidTaskException e) {
log.warn("Caught expected exception: {}", e.getMessage());
}
// 3. Retrieve all tasks
log.info("\n--- All Tasks ---");
List<Task> allTasks = taskService.getAllTasks();
allTasks.forEach(task -> log.info(" - {}", task));
// 4. Update a task
log.info("\n--- Updating Task 1 ---");
Task updatedTask1 = taskService.updateTaskDescription(task1.id(), "Master Java 25 features and Project Loom");
log.info("Updated task 1 description: {}", updatedTask1);
Task completedTask2 = taskService.updateTaskStatus(task2.id(), true);
log.info("Completed task 2: {}", completedTask2);
// Try updating a non-existent task
try {
taskService.updateTaskDescription(UUID.randomUUID(), "Non-existent task update");
} catch (TaskNotFoundException e) {
log.warn("Caught expected exception: {}", e.getMessage());
}
// 5. Retrieve a single task
log.info("\n--- Retrieving Task 3 ---");
Task retrievedTask3 = taskService.getTaskById(task3.id());
log.info("Retrieved task 3: {}", retrievedTask3);
// 6. Delete a task
log.info("\n--- Deleting Task 3 ---");
taskService.deleteTask(task3.id());
log.info("Task 3 deleted.");
// Try deleting a non-existent task
try {
taskService.deleteTask(task3.id()); // Try deleting again
} catch (TaskNotFoundException e) {
log.warn("Caught expected exception: {}", e.getMessage());
}
// 7. Verify remaining tasks
log.info("\n--- Remaining Tasks ---");
allTasks = taskService.getAllTasks();
allTasks.forEach(task -> log.info(" - {}", task));
log.info("To-Do List Application demonstration finished.");
}
}
You can run this App.java from your IDE or using mvn exec:java -Dexec.mainClass="com.example.todolist.App". Observe the log output to see the service layer in action.
Summary & Next Steps
Congratulations! You’ve successfully implemented a robust, testable, and production-ready service layer for our To-Do List application. We’ve covered:
- The importance of separating business logic into a dedicated service layer.
- Designing an immutable
Taskmodel using Java Records. - Defining a
TaskRepositoryinterface for data access abstraction. - Implementing an
InMemoryTaskRepositoryfor initial development and testing. - Crafting
TaskServicewith comprehensive business logic, input validation, and custom exception handling. - Writing thorough unit tests using JUnit 5 and Mockito to ensure the correctness of both the repository and the service layer.
- Discussing critical production considerations like error handling, performance, security, and logging.
This chapter has laid a strong architectural foundation. The TaskService now provides all the necessary operations to manage To-Do items, independent of how they are presented to the user or how they are persistently stored.
In the next chapter (Chapter 12), we will explore how to integrate a simple user interface (likely a command-line interface initially, or a basic web interface using a lightweight framework) with our TaskService, allowing users to interact with our To-Do List application. We’ll connect the presentation layer to our robust business logic.