STAGE 14 OF 37

Stage 14: bring the concepts together in a Todo list application

Learning path / Stage 14 of 37

Before you start: use JDK 21+ and Maven 3.9+. Extract this stage’s project and open a terminal in its pom.xml folder. Complete the setup below, including a database connection where required, then run mvn spring-boot:run. Use the port specified for this stage and stop with Ctrl+C.

Goal: run the same projects/todo-mvc application explained later in the Spring Boot MVC manual. This is our shared Todo list, with add, list, edit, completed status and delete operations. The Java classes and templates below are the existing manual’s files, not a different version of the example.

Download todo-mvc.zip · Complete file-by-file guide

Browser form → Tomcat / security filters → DispatcherServlet → TodoController → TodoService → TodoRepository → MongoDB

1. Run a familiar application before studying its layers

Extract todo-mvc.zip. Open the todo-mvc folder containing pom.xml in your IDE and terminal. Use JDK 21+ and Maven 3.9+. Internet is needed for the first dependency download. The ZIP also supplies Maven wrapper scripts; the commands here use your installed Maven. No separate Tomcat installation, React application or Postman client is required.

Check your tools — one line at a time
java -version
javac -version
mvn -version

2. Choose the database and port

Reuse your Stage 13 MongoDB connection. For Atlas, the database user needs read/write permission for todo_mvc, and the cluster’s IP access list must allow your current public IP. Keep real credentials outside the source files. For a local MongoDB server, use mongodb://127.0.0.1:27017 as the URI instead.

src/main/resources/application.properties — complete file
spring.application.name=todo-mvc
server.port=8080
spring.mongodb.uri=${MONGODB_URI}
spring.mongodb.database=todo_mvc
spring.data.mongodb.auto-index-creation=true
server.error.include-message=never
server.error.include-stacktrace=never
spring.thymeleaf.cache=false

The application’s default web port is 8080. If another application uses it, change the server.port line to server.port=8092, save, restart and use 8092 in all browser URLs below. MongoDB’s server port is separate. The database is todo_mvc and the collection is todos; this stage does not use the products database or seed product data.

The database starts empty if you have never used it. If you already ran the later MVC manual, your existing tasks appear because this is the same application and database. Do not remove existing tasks just to follow this stage.

3. Supply the URI and start

Choose the commands for your operating system. Replace every placeholder with your own cluster information; percent-encode reserved characters in username/password values. The same terminal must set the variable and start Maven. Setting MONGODB_URI in a new terminal is necessary again; these commands are session settings. A .env file is not automatically read by them.

Windows Command Prompt — enter PowerShell first
powershell
Windows PowerShell
$env:MONGODB_URI='mongodb+srv://YOUR_USER:YOUR_ENCODED_PASSWORD@YOUR_CLUSTER.mongodb.net/?retryWrites=true&w=majority'
mvn spring-boot:run
macOS / Linux terminal
export MONGODB_URI='mongodb+srv://YOUR_USER:YOUR_ENCODED_PASSWORD@YOUR_CLUSTER.mongodb.net/?retryWrites=true&w=majority'
mvn spring-boot:run

For a local server, replace the URI in the chosen command with mongodb://127.0.0.1:27017. That server must already be running. Keep the Java terminal open. Open http://localhost:8080/todos or the port you selected. Visiting / redirects to /todos. Stop with Ctrl+C.

4. Try the complete page flow

  1. On a fresh database the list says No todos yet. Add your first task.
  2. Click Add a todo. Enter Learn Spring MVC, leave Completed unchecked and click Save todo.
  3. The browser returns to /todos. Your task appears with status Pending.
  4. Click Edit. Change the title to Explain Spring MVC, check Completed and save. The list shows the new title and Done.
  5. Stop and restart the app with the same connection settings. The edited task remains because it is stored in MongoDB.
  6. Delete only the task you created for this exercise. Its row disappears. Other tasks remain.

The application has no login or task ownership. It is a shared Todo list. The later JWT and React manual introduces each user’s personal list.

5. Connect every file to a concept you already know

Concept and earlier stageExact code to locateWhat to recognize
Startup, container and DispatcherServlet — Stages 1–2TodoApplication.javaBoot starts the MVC application and embedded server. DispatcherServlet coordinates requests to our controller.
Controller, routes and path variables — Stages 2–5TodoController: list(), edit(), update(), delete()@GetMapping and @PostMapping choose methods; @PathVariable supplies a todo ID from the URL.
Model and Thymeleaf — Stages 6–7list(Model model), list.html and form.htmlThe controller supplies named data; Thymeleaf produces HTML.
Bean, DI and singleton — Stage 8TodoController(TodoService service)Spring supplies the service bean to the controller constructor. A Todo data object is not automatically a Spring bean.
Service and business operations — Stage 9TodoService.javaThe service trims the title, retrieves existing tasks and calls the repository.
Repository / DAO — Stages 10–11TodoRepository extends MongoRepository<Todo, String>Spring Data supplies data-access methods. We do not implement save(), findAll() or findById().
Configuration and factory method — Stage 12WebSecurity.java: @Configuration and @Bean web(HttpSecurity http)Spring supplies the method argument and manages the returned security-filter-chain object.
MongoDB connectivity — Stage 13application.properties and @Document("todos")The URI selects the server; todo_mvc is the database; todos is the collection.

The switch from products to todos changes the data fields, not the architecture. Todo has title and completed fields. Its ID is a String; MongoDB supplies an identifier when a new Todo is saved. We do not assign numeric IDs as we did in the product seed file.

6. Trace the list request

Follow GET /todos through the existing files
Browser requests GET /todos
→ TodoController.list(Model model)
→ service.list()
→ repository.findAll() reads MongoDB
→ model.addAttribute("todos", tasks)
→ return "list"
→ Thymeleaf renders templates/list.html
→ browser receives HTML

In the controller, the actual statement is model.addAttribute("todos",service.list()). The flow above names the returned list tasks only to make the sequence easier to read. The table repeats rows using th:each and displays title and completed status. The Java Todo class describes data; each Todo is not a Spring bean.

7. Trace form binding, validation and saving

Follow the Add a todo operation
GET /todos/new
→ controller adds a new TodoForm and action="/todos" to Model
→ return "form"
→ form.html displays the inputs

POST /todos
→ security checks the form token
→ @ModelAttribute binds input fields into TodoForm
→ @Valid checks its constraints
→ BindingResult holds validation errors
→ if invalid: return "form" and display errors
→ if valid: service.create(title, completed)
→ repository.save(new Todo(...))
→ redirect:/todos
→ a new GET displays the saved task

Two related names: model.addAttribute supplies named data to a view. The @ModelAttribute annotation here also tells Spring to bind submitted values to the TodoForm object. th:object selects that form object in the template; th:field connects each input to one of its properties.

TodoForm is the input object. Todo is the stored document. Keeping the form separate lets us describe allowed inputs and validation without treating every document field as a user-editable value. @NotBlank rejects a blank title and @Size limits it to 120 characters. These are Jakarta Validation annotations integrated by the validation starter. BindingResult immediately follows the validated form argument, allowing the controller to show errors rather than save invalid data.

8. Understand the small security configuration

WebSecurity is a concrete reuse of Stage 12: @Configuration contains an @Bean factory method, web(HttpSecurity http), whose argument Spring supplies. It returns a SecurityFilterChain. For this application, requests are permitted without a login, while CSRF protection remains enabled.

Thymeleaf’s th:action forms integrate with Spring Security to render a hidden CSRF token in POST forms. Use the application’s browser forms so the token and session are sent together. Posting without a valid token normally receives HTTP 403. CSRF protection does not give tasks personal ownership; that comes in the later authentication application.

9. Match each route to its job

Method and pathController methodResult
GET /home()Redirect to /todos.
GET /todoslist()Display all tasks through list.html.
GET /todos/newnewTodo()Display a blank form.html.
POST /todoscreate()Validate and create, then redirect; redisplay the form if invalid.
GET /todos/{id}/editedit()Load one task into the edit form.
POST /todos/{id}update()Validate and save the existing task.
POST /todos/{id}/deletedelete()Remove the existing task and redirect.

These HTML forms use GET for reads and POST for changes. The controller loads an existing task before updating or deleting it. An absent task returns 404 instead of creating a replacement.

10. Verify the behavior

  • Add a task, edit it, mark it completed, restart and confirm persistence, then delete only your test task.
  • Submit an empty title: the browser’s required check usually stops the request. Submit spaces as the title: the server returns the form with Enter a title, and creates no task.
  • The browser input limits title length to 120; @Size also enforces that limit on the server. Client-side controls do not replace server validation.
  • Open /todos/000000000000000000000000/edit: expect 404 if that ID does not exist.
  • After saving, refresh the resulting list page. It repeats GET /todos and does not create another task.

11. Read the same code again in the later tutorial

Continue to Spring Boot MVC: complete source and file explanations. Keep this running project open. When the manual mentions a controller, service, repository, form object, model attribute or template, locate it in these same files. The later Spring object-creation explanation and request-lifecycle explanation now have a concrete application to relate to.

Complete files to study

The project ZIP and standalone README.html contain the complete application and supplied tests. These are the canonical todo-mvc files used by the later manual.

pom.xml
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version><relativePath/></parent>
  <groupId>com.example</groupId><artifactId>todo-mvc</artifactId><version>1.0.0</version>
  <properties><java.version>21</java.version></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-mongodb</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
  </dependencies>
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
src/main/resources/application.properties
src/main/resources/application.properties
spring.application.name=todo-mvc
server.port=8080
spring.mongodb.uri=${MONGODB_URI}
spring.mongodb.database=todo_mvc
spring.data.mongodb.auto-index-creation=true
server.error.include-message=never
server.error.include-stacktrace=never
spring.thymeleaf.cache=false
src/main/java/com/example/todo/TodoApplication.java
src/main/java/com/example/todo/TodoApplication.java
package com.example.todo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TodoApplication {
    public static void main(String[] args) {
        SpringApplication.run(TodoApplication.class, args);
    }
}
src/main/java/com/example/todo/Todo.java
src/main/java/com/example/todo/Todo.java
package com.example.todo;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

@Document("todos")
public class Todo {
    @Id private String id;
    private String title;
    private boolean completed;
    public Todo() {}
    public Todo(String title, boolean completed) { this.title=title; this.completed=completed; }
    public String getId() { return id; }
    public void setId(String id) { this.id=id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title=title; }
    public boolean isCompleted() { return completed; }
    public void setCompleted(boolean completed) { this.completed=completed; }
}
src/main/java/com/example/todo/TodoForm.java
src/main/java/com/example/todo/TodoForm.java
package com.example.todo;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class TodoForm {
    @NotBlank(message="Enter a title")
    @Size(max=120, message="Use at most 120 characters")
    private String title;
    private boolean completed;
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title=title; }
    public boolean isCompleted() { return completed; }
    public void setCompleted(boolean completed) { this.completed=completed; }
}
src/main/java/com/example/todo/TodoRepository.java
src/main/java/com/example/todo/TodoRepository.java
package com.example.todo;

import org.springframework.data.mongodb.repository.MongoRepository;
public interface TodoRepository extends MongoRepository<Todo, String> {}
src/main/java/com/example/todo/TodoService.java
src/main/java/com/example/todo/TodoService.java
package com.example.todo;

import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.NOT_FOUND;

@Service
public class TodoService {
    private final TodoRepository repository;
    public TodoService(TodoRepository repository) { this.repository=repository; }
    public List<Todo> list() {
        return repository.findAll().stream().sorted(Comparator.comparing(Todo::getId)).toList();
    }
    public Todo get(String id) {
        return repository.findById(id).orElseThrow(() -> new ResponseStatusException(NOT_FOUND,"Todo not found"));
    }
    public Todo create(String title, boolean completed) {
        return repository.save(new Todo(title.strip(),completed));
    }
    public Todo update(String id, String title, boolean completed) {
        Todo todo=get(id); todo.setTitle(title.strip()); todo.setCompleted(completed);
        return repository.save(todo);
    }
    public void delete(String id) { Todo todo=get(id); repository.deleteById(todo.getId()); }
}
src/main/java/com/example/todo/TodoController.java
src/main/java/com/example/todo/TodoController.java
package com.example.todo;

import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;

@Controller
public class TodoController {
    private final TodoService service;
    public TodoController(TodoService service) { this.service=service; }
    @GetMapping("/") String home() { return "redirect:/todos"; }
    @GetMapping("/todos") String list(Model model) {
        model.addAttribute("todos",service.list()); return "list";
    }
    @GetMapping("/todos/new") String newTodo(Model model) {
        model.addAttribute("todoForm",new TodoForm()); model.addAttribute("action","/todos");
        return "form";
    }
    @PostMapping("/todos") String create(@Valid @ModelAttribute("todoForm") TodoForm form,
                                        BindingResult errors, Model model) {
        if(errors.hasErrors()) { model.addAttribute("action","/todos"); return "form"; }
        service.create(form.getTitle(),form.isCompleted()); return "redirect:/todos";
    }
    @GetMapping("/todos/{id}/edit") String edit(@PathVariable String id,Model model) {
        Todo todo=service.get(id); TodoForm form=new TodoForm();
        form.setTitle(todo.getTitle()); form.setCompleted(todo.isCompleted());
        model.addAttribute("todoForm",form); model.addAttribute("action","/todos/"+id);
        return "form";
    }
    @PostMapping("/todos/{id}") String update(@PathVariable String id,
        @Valid @ModelAttribute("todoForm") TodoForm form,BindingResult errors,Model model) {
        if(errors.hasErrors()) { model.addAttribute("action","/todos/"+id); return "form"; }
        service.update(id,form.getTitle(),form.isCompleted()); return "redirect:/todos";
    }
    @PostMapping("/todos/{id}/delete") String delete(@PathVariable String id) {
        service.delete(id); return "redirect:/todos";
    }
}
src/main/java/com/example/todo/WebSecurity.java
src/main/java/com/example/todo/WebSecurity.java
package com.example.todo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class WebSecurity {
    @Bean SecurityFilterChain web(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a.anyRequest().permitAll());
        return http.build();
    }
}
src/main/resources/templates/list.html
src/main/resources/templates/list.html
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Classroom todos</title><link rel="stylesheet" th:href="@{/style.css}"></head>
<body><main><h1>Classroom todos</h1><a th:href="@{/todos/new}">Add a todo</a>
<p th:if="${#lists.isEmpty(todos)}">No todos yet. Add your first task.</p>
<table th:unless="${#lists.isEmpty(todos)}"><thead><tr><th>Title</th><th>Status</th><th>Actions</th></tr></thead>
<tbody><tr th:each="todo : ${todos}">
<td th:text="${todo.title}"></td><td th:text="${todo.completed ? 'Done' : 'Pending'}"></td>
<td><a th:href="@{/todos/{id}/edit(id=${todo.id})}">Edit</a>
<form th:action="@{/todos/{id}/delete(id=${todo.id})}" method="post">
<button type="submit">Delete</button></form></td></tr></tbody></table>
</main></body></html>
src/main/resources/templates/form.html
src/main/resources/templates/form.html
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Todo form</title><link rel="stylesheet" th:href="@{/style.css}"></head>
<body><main><h1>Todo form</h1>
<form th:action="@{${action}}" th:object="${todoForm}" method="post">
<label for="title">Title</label><input id="title" th:field="*{title}" maxlength="120" required>
<p class="error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}"></p>
<label><input type="checkbox" th:field="*{completed}"> Completed</label>
<button type="submit">Save todo</button></form>
<a th:href="@{/todos}">Back to list</a></main></body></html>

Troubleshooting

SymptomCheck
Port 8080 is occupiedSet server.port=8092 in application.properties, restart and change the browser URL.
MONGODB_URI cannot be resolvedSet it in the same terminal or IDE run configuration that launches Java.
MongoDB timeout or authentication errorCheck the running server/Atlas cluster, database credentials, IP access list and read/write permission for todo_mvc.
HTTP 403 when savingUse a freshly loaded form from this app and retain its cookies and hidden token. Do not disable CSRF to work around a missing token.
The list is emptyA new todo_mvc database has no seed tasks. Add one through the form.
An edit page returns 404That task ID is absent or was deleted; return to the current list.

Practice

Before adding new features, narrate one create request and point to each participating file. Then change the page heading and the @NotBlank validation message, restart and observe which part of the application each change affects. The included TodoServiceTest verifies trimming a title and rejecting an update to an absent task; run mvn test to execute it.

After Stage 14: the Spring words to recognize

TermOur concrete exampleMeaning in this application
@ModelAttribute — form bindingTodoForm form in create() and update()Spring populates a form object from submitted input fields. This differs from the earlier model.addAttribute call that supplies named data to a view.
@Valid and BindingResultcreate(@Valid @ModelAttribute(...) TodoForm form, BindingResult errors, ...)Run validation and inspect its result before saving. BindingResult immediately follows the validated argument.
Validation constraints@NotBlank and @Size(max=120) on TodoForm.titleJakarta Validation rules integrated by Spring: reject a blank title or more than 120 characters.
Post/Redirect/Getreturn "redirect:/todos" after a successful writePOST changes data; the redirected GET displays the list. Refreshing that result page repeats the read.
CSRF tokenHidden input rendered in our POST formsSpring Security verifies the submitted token. Thymeleaf integrates it into these th:action forms. We keep this protection enabled.

Already familiar: controller, service, repository, dependency injection, model attributes for views, templates, beans, factory methods and MongoDB connectivity now work together in one application.

Check: Point to the line that reads form data, the line that validates it, the service operation, the repository call and the template that displays the resulting list.

Continue: 1. Connect the new concepts to familiar ones

Concepts used in this stage

Use the right column for a quick reminder, or open a concept above for its full explanation.

Java 21 · Spring Boot 4.1.1 · Edupoly