Real-World Project
Plan and build a small layered Spring MVC application end to end - controller, service, and a JdbcTemplate-backed repository.
Introduction
Every lesson so far has looked at one piece of Spring in isolation. This final lesson puts them together by planning and building a small task-tracking application - TaskBoard - with a full controller-service-repository stack, form handling, validation, and centralized error handling. Nothing here is new; the goal is to see how the pieces fit into one coherent application.
- A Task domain model and an in-memory-friendly database table.
- A TaskRepository built on JdbcTemplate.
- A TaskService containing the business logic.
- A TaskController handling listing, creating, and completing tasks.
- Form validation and centralized exception handling tying it all together.
Planning the Application
Before writing code, it helps to write down exactly what the application needs to do. TaskBoard needs to: list all tasks, let a user create a new task with a title and due date, let a user mark a task complete, and show a friendly error page if a requested task does not exist. That short list maps directly onto the layers you have learned throughout this course.
| Requirement | Layer Responsible |
|---|---|
| List all tasks | Controller (GET) + Service + Repository |
| Create a task with validation | Controller (POST) + @Valid form object |
| Mark a task complete | Controller (POST) + Service |
| Handle a missing task gracefully | @ControllerAdvice + custom exception |
The Domain Model
Start with a simple Task class representing one row in the database, plus a TaskForm used specifically for binding and validating the create-task submission.
public class Task { private Long id; private String title; private LocalDate dueDate; private boolean completed;
public Task() {}
public Task(Long id, String title, LocalDate dueDate, boolean completed) { this.id = id; this.title = title; this.dueDate = dueDate; this.completed = completed; }
// getters and setters omitted for brevity}
public class TaskForm {
@NotBlank(message = "Title is required") @Size(max = 100, message = "Title must be under 100 characters") private String title;
@NotNull(message = "Due date is required") @FutureOrPresent(message = "Due date cannot be in the past") private LocalDate dueDate;
// getters and setters}The Repository Layer
TaskRepository wraps JdbcTemplate exactly the way you built BookRepository earlier in the course - a reusable RowMapper plus focused query and update methods.
@Repositorypublic class TaskRepository {
private final JdbcTemplate jdbcTemplate;
public TaskRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; }
private final RowMapper<Task> taskRowMapper = (rs, rowNum) -> new Task( rs.getLong("id"), rs.getString("title"), rs.getDate("due_date").toLocalDate(), rs.getBoolean("completed") );
public List<Task> findAll() { return jdbcTemplate.query( "SELECT id, title, due_date, completed FROM tasks ORDER BY due_date", taskRowMapper); }
public Task findById(long id) { String sql = "SELECT id, title, due_date, completed FROM tasks WHERE id = ?"; return jdbcTemplate.queryForObject(sql, taskRowMapper, id); }
public int save(Task task) { String sql = "INSERT INTO tasks (title, due_date, completed) VALUES (?, ?, ?)"; return jdbcTemplate.update(sql, task.getTitle(), task.getDueDate(), task.isCompleted()); }
public int markCompleted(long id) { return jdbcTemplate.update("UPDATE tasks SET completed = TRUE WHERE id = ?", id); }}The Service Layer
TaskService owns the business rules - here, translating a missing task into a specific TaskNotFoundException so the controller layer never has to think about database details.
@Servicepublic class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) { this.taskRepository = taskRepository; }
public List<Task> listTasks() { return taskRepository.findAll(); }
public Task getTask(long id) { try { return taskRepository.findById(id); } catch (EmptyResultDataAccessException ex) { throw new TaskNotFoundException("Task with id " + id + " was not found"); } }
public void createTask(TaskForm form) { Task task = new Task(null, form.getTitle(), form.getDueDate(), false); taskRepository.save(task); }
public void completeTask(long id) { getTask(id); // ensures it exists, throws TaskNotFoundException otherwise taskRepository.markCompleted(id); }}The Controller Layer
TaskController stays thin - parse input, delegate to the service, choose a view - exactly the shape every earlier lesson pointed toward.
@Controller@RequestMapping("/tasks")public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) { this.taskService = taskService; }
@GetMapping public String listTasks(Model model) { model.addAttribute("tasks", taskService.listTasks()); return "task-list"; }
@GetMapping("/new") public String showCreateForm(Model model) { model.addAttribute("taskForm", new TaskForm()); return "task-form"; }
@PostMapping("/new") public String createTask(@Valid @ModelAttribute TaskForm taskForm, BindingResult bindingResult) { if (bindingResult.hasErrors()) { return "task-form"; } taskService.createTask(taskForm); return "redirect:/tasks"; }
@PostMapping("/{id}/complete") public String completeTask(@PathVariable long id) { taskService.completeTask(id); return "redirect:/tasks"; }}Validation and Error Handling
TaskForm's @NotBlank and @FutureOrPresent constraints are checked automatically by @Valid, and a @ControllerAdvice class handles the case where completeTask or a future "task details" page is asked for a task that does not exist.
@ControllerAdvicepublic class TaskExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class) public String handleTaskNotFound(TaskNotFoundException ex, Model model) { model.addAttribute("message", ex.getMessage()); return "error-404"; }}Click Run to see what this code prints.
Testing the Application
Following the previous lesson, TaskService is unit tested with Mockito by mocking TaskRepository, keeping the test fast and independent of any real database.
@ExtendWith(MockitoExtension.class)class TaskServiceTest {
@Mock private TaskRepository taskRepository;
@InjectMocks private TaskService taskService;
@Test void completingMissingTaskThrows() { when(taskRepository.findById(99L)) .thenThrow(new EmptyResultDataAccessException(1));
assertThrows(TaskNotFoundException.class, () -> taskService.completeTask(99L)); }}Where to Go from Here
TaskBoard uses plain Spring - explicit XML-free, annotation-based configuration and a manually configured DataSource. The natural next step is Spring Boot, which auto-configures almost everything shown in this project (DataSource, JdbcTemplate, DispatcherServlet, and more) from a handful of properties, letting you focus even more on business logic and far less on wiring.
TaskBoard touches every layer covered in this course: a domain model, a JdbcTemplate repository, a service with business rules, a validated form-backed controller, and centralized exception handling. That is the same shape used by production Spring applications, just smaller.
Common Mistakes
- Skipping the planning step and jumping straight into code, leading to a controller that grows every responsibility.
- Letting the controller call TaskRepository directly, bypassing the service layer.
- Forgetting to convert a database-level "not found" signal into a meaningful application exception.
- Not validating the form object before using it to build a Task.
- Testing only the "happy path" and skipping the case where a task genuinely does not exist.
Best Practices
- Plan the layers (controller, service, repository) before writing any code, even for a small project.
- Keep each layer talking only to the layer directly below it - controller to service, service to repository.
- Translate low-level data access exceptions into meaningful, specific application exceptions in the service layer.
- Validate form input before it reaches the service layer.
- Write at least one unit test per non-trivial service method, including failure cases.
Frequently Asked Questions
Keeping data-access-specific exceptions inside the service and repository layers means the controller only ever needs to know about application-level exceptions, keeping it decoupled from how data happens to be stored.
Yes - the layering (controller, service, repository) stays the same either way. Only the repository implementation would change, which is exactly the benefit of keeping data access behind its own layer.
Very little of the application code - Spring Boot mainly removes manual configuration like the DataSource and JdbcTemplate beans, replacing them with auto-configuration driven by a few properties.
Key Takeaways
- A real Spring MVC application is the same layers covered throughout this course, combined: controller, service, repository.
- Planning requirements against layers before coding keeps responsibilities from blurring together.
- Validation and centralized exception handling turn a working prototype into a robust application.
- Unit testing the service layer with mocked dependencies verifies business rules quickly and reliably.
- Spring Boot builds on exactly this foundation by auto-configuring the wiring, not by changing the architecture.
Summary
TaskBoard brought together everything from this course into one small, coherent application - proof that the individual pieces (IoC, beans, MVC, JDBC, validation, testing) are really one connected system once you have built something with all of them at once.