LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 3024 min read

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.

What You Will Build
  • 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.

RequirementLayer Responsible
List all tasksController (GET) + Service + Repository
Create a task with validationController (POST) + @Valid form object
Mark a task completeController (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.

@Repository
public 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.

@Service
public 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.

@ControllerAdvice
public class TaskExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public String handleTaskNotFound(TaskNotFoundException ex, Model model) {
model.addAttribute("message", ex.getMessage());
return "error-404";
}
}
End-to-End Flow for Creating a Task

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.

You Built a Real Layered Application

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

Avoid These 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.

Spring Framework Course Completed!

You have successfully completed all 30 lessons - from IoC and Dependency Injection through Spring MVC, AOP, and database access. You now have a genuinely solid foundation for building enterprise Spring applications, including Spring Boot.

Browse All Courses →