Stage 14: bring the concepts together in a Todo list application
Learning path / Stage 14 of 37
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
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.
java -version
javac -version
mvn -version2. 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.
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.
powershell$env:MONGODB_URI='mongodb+srv://YOUR_USER:YOUR_ENCODED_PASSWORD@YOUR_CLUSTER.mongodb.net/?retryWrites=true&w=majority'
mvn spring-boot:runexport MONGODB_URI='mongodb+srv://YOUR_USER:YOUR_ENCODED_PASSWORD@YOUR_CLUSTER.mongodb.net/?retryWrites=true&w=majority'
mvn spring-boot:runFor 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
- On a fresh database the list says No todos yet. Add your first task.
- Click Add a todo. Enter Learn Spring MVC, leave Completed unchecked and click Save todo.
- The browser returns to /todos. Your task appears with status Pending.
- Click Edit. Change the title to Explain Spring MVC, check Completed and save. The list shows the new title and Done.
- Stop and restart the app with the same connection settings. The edited task remains because it is stored in MongoDB.
- 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 stage | Exact code to locate | What to recognize |
|---|---|---|
| Startup, container and DispatcherServlet — Stages 1–2 | TodoApplication.java | Boot starts the MVC application and embedded server. DispatcherServlet coordinates requests to our controller. |
| Controller, routes and path variables — Stages 2–5 | TodoController: list(), edit(), update(), delete() | @GetMapping and @PostMapping choose methods; @PathVariable supplies a todo ID from the URL. |
| Model and Thymeleaf — Stages 6–7 | list(Model model), list.html and form.html | The controller supplies named data; Thymeleaf produces HTML. |
| Bean, DI and singleton — Stage 8 | TodoController(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 9 | TodoService.java | The service trims the title, retrieves existing tasks and calls the repository. |
| Repository / DAO — Stages 10–11 | TodoRepository extends MongoRepository<Todo, String> | Spring Data supplies data-access methods. We do not implement save(), findAll() or findById(). |
| Configuration and factory method — Stage 12 | WebSecurity.java: @Configuration and @Bean web(HttpSecurity http) | Spring supplies the method argument and manages the returned security-filter-chain object. |
| MongoDB connectivity — Stage 13 | application.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
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 HTMLIn 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
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 taskTwo 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 path | Controller method | Result |
|---|---|---|
| GET / | home() | Redirect to /todos. |
| GET /todos | list() | Display all tasks through list.html. |
| GET /todos/new | newTodo() | Display a blank form.html. |
| POST /todos | create() | Validate and create, then redirect; redisplay the form if invalid. |
| GET /todos/{id}/edit | edit() | Load one task into the edit form. |
| POST /todos/{id} | update() | Validate and save the existing task. |
| POST /todos/{id}/delete | delete() | 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
<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
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
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
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
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
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
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
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
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
<!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
<!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
| Symptom | Check |
|---|---|
| Port 8080 is occupied | Set server.port=8092 in application.properties, restart and change the browser URL. |
| MONGODB_URI cannot be resolved | Set it in the same terminal or IDE run configuration that launches Java. |
| MongoDB timeout or authentication error | Check the running server/Atlas cluster, database credentials, IP access list and read/write permission for todo_mvc. |
| HTTP 403 when saving | Use 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 empty | A new todo_mvc database has no seed tasks. Add one through the form. |
| An edit page returns 404 | That 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
| Term | Our concrete example | Meaning in this application |
|---|---|---|
| @ModelAttribute — form binding | TodoForm 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 BindingResult | create(@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.title | Jakarta Validation rules integrated by Spring: reject a blank title or more than 120 characters. |
| Post/Redirect/Get | return "redirect:/todos" after a successful write | POST changes data; the redirected GET displays the list. Refreshing that result page repeats the read. |
| CSRF token | Hidden input rendered in our POST forms | Spring 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.
Concepts used in this stage
Use the right column for a quick reminder, or open a concept above for its full explanation.