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
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 -versionReuse 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.
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.
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.
| 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.
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.
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.
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.
| 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.
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.
Create each file at its indicated path if building manually with installed Maven. The downloadable ZIP additionally includes the Maven wrapper scripts.
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
<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>
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; }
}
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);
}
}
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";
}
}
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; }
}
package com.example.todo;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface TodoRepository extends MongoRepository<Todo, String> {}
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()); }
}
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();
}
}
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
body{font-family:system-ui,sans-serif;background:#f2f6f3;color:#17352a;margin:0}main{max-width:850px;margin:40px auto;background:white;padding:32px;border-radius:12px}input,button{font:inherit;padding:9px;margin:6px}input:not([type=checkbox]){display:block;width:min(90%,450px)}button{cursor:pointer;background:#176746;color:white;border:0;border-radius:5px}table{width:100%;margin-top:24px;border-collapse:collapse}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}td form{display:inline}.error{color:#ae2525}a{color:#176746}
<!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>
<!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>
package com.example.todo;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class TodoServiceTest {
@Test void missingUpdateCannotInsert() {
TodoRepository repo=mock(TodoRepository.class);
when(repo.findById("missing")).thenReturn(Optional.empty());
TodoService service=new TodoService(repo);
assertEquals(404,assertThrows(ResponseStatusException.class,
() -> service.update("missing","Task",false)).getStatusCode().value());
verify(repo,never()).save(any());
}
@Test void createTrimsTitle() {
TodoRepository repo=mock(TodoRepository.class);
when(repo.save(any(Todo.class))).thenAnswer(i -> i.getArgument(0));
Todo todo=new TodoService(repo).create(" Teach Spring ",false);
assertEquals("Teach Spring",todo.getTitle()); assertFalse(todo.isCompleted());
}
}
| 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. |
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.
| 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.