Spring / Todo Academy
← Edupoly tutorialsJava → Spring Boot
TUTORIAL

Spring Boot MVC

Implement a complete server-rendered todo list and understand every layer behind the form.

Java 21Spring Boot 4.1.1Maven 3.9+MongoDB Atlas
How to use this manual: Read Spring foundations and complete Atlas setup once. Then follow the numbered steps here in order. Every source file is included below with a copy button, its exact relative path, and an explanation. Downloading the bundle is an alternative to creating the files by hand.

1. Understand the application

This app provides a shared todo list. You can add a task, list all tasks, edit the title and completed status, and delete a task. Documents persist in Atlas database todo_mvc. The browser receives server-rendered HTML; no React or Postman is required for this lab.

HTML form → TodoController → TodoService → TodoRepository → Atlas

Spring Framework provides dependency injection and MVC. Spring Boot starts and configures the application. Thymeleaf renders templates. Spring Data MongoDB maps and stores documents. Spring Security supplies CSRF protection on the forms.

2. Create the project and folders

Choose a working folder, such as C:\training, in which you can create projects. In the downloaded bundle, open projects/todo-mvc directly in your IDE as a Maven project. To build from this manual instead, create a folder called todo-mvc, then create every file shown in the next step at its exact path.

PowerShell / Bash · manual creation only
mkdir todo-mvc
cd todo-mvc

A directory separator may be / or \ on Windows. Match Java class names and filenames exactly. Do not place Java source files in the project root or inside resources. The first line of each Java file is package com.example.todo;.

Project paths · relative to todo-mvc
pom.xml
src/main/java/com/example/todo/TodoApplication.java
src/main/resources/application.properties
.gitignore
src/main/java/com/example/todo/Todo.java
src/main/java/com/example/todo/TodoRepository.java
src/main/java/com/example/todo/TodoService.java
src/main/java/com/example/todo/TodoForm.java
src/main/java/com/example/todo/WebSecurity.java
src/main/java/com/example/todo/TodoController.java
src/main/resources/templates/list.html
src/main/resources/templates/form.html
src/main/resources/static/style.css
src/test/java/com/example/todo/TodoServiceTest.java

Import or reload Maven after saving pom.xml. The package root is src/main/java/com/example/todo. Tests go under src/test/java/com/example/todo. Resources go under src/main/resources. No Lombok, code-generation step, or omitted helper class is required.

3. Write the complete source files

Create the files below in the order shown. Save each whole code block as the indicated file. Test files are also included; you can copy them after the application files.

pom.xml Download file

The parent pins Spring Boot 4.1.1 and manages compatible dependency versions. Web MVC supplies DispatcherServlet and embedded Tomcat. MongoDB supplies Spring Data repositories and the driver; validation supplies Jakarta Bean Validation. Java 21 is the example baseline. The Boot Maven plugin builds an executable JAR.

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/java/com/example/todo/TodoApplication.java Download file

This main method starts the Spring container and the embedded web server. @SpringBootApplication combines configuration, auto-configuration, and component scanning. Keep every application class below com.example.todo so Spring discovers it.

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/resources/application.properties Download file

Boot 4 uses spring.mongodb.uri and spring.mongodb.database. Environment placeholders keep credentials out of source. Each lab has its own database and port. Automatic index creation is enabled for the example database; personal users require a unique email index. In a deployed system, create and review indexes through a migration process.

Source
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

.gitignore Download file

Build output and local secret files must stay out of Git. An .env file is not automatically loaded by Spring Boot; use the documented terminal environment variables.

Source
target/
.env
*.log
node_modules/
dist/

src/main/java/com/example/todo/Todo.java Download file

@Document maps this Java class to the todos collection. @Id maps id to MongoDB’s _id. A String id can represent the generated ObjectId. The no-argument constructor and accessors support mapping and serialization. completed defaults to false; title is stored as a string. This is a document model, not a relational @Entity.

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/TodoRepository.java Download file

Spring creates a repository implementation at runtime. Todo is the document type and String is the identifier type. Inherited methods include findAll, findById, save, and deleteById. save inserts a new object with no id or replaces an existing document. There is no SQL or JDBC connection here.

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 Download file

Constructor injection makes dependencies explicit and supports tests with a mock repository. The service owns business operations; controllers only translate HTTP. get throws 404 when missing. update reads first, so an unknown id cannot become an accidental insert. strip removes surrounding whitespace. Sorting makes the displayed task order stable. Reading then saving is simple but concurrent edits use last-write-wins; add @Version as an extension.

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/TodoForm.java Download file

The form object contains only editable fields. Binding a separate form prevents visitors from setting a database id. @NotBlank rejects null, empty, or whitespace titles; @Size bounds storage and layout. Server validation still applies when someone bypasses HTML validation.

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/WebSecurity.java Download file

This first lab is a shared todo list. All pages are public, while Spring Security keeps CSRF protection for HTML forms. Thymeleaf’s th:action forms receive a hidden CSRF field automatically. No login is configured for this lab. GET requests display pages; POST requests change data.

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/java/com/example/todo/TodoController.java Download file

Unlike a Servlet’s doGet/doPost, annotated methods declare routes. @Controller returns a view name resolved under templates/. Model is the data passed to the template. @ModelAttribute binds form fields. BindingResult must immediately follow the validated form argument. On validation failure the same form is rendered with errors. After success, redirect:/todos implements Post/Redirect/Get and prevents refreshing from resubmitting a successful form.

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/resources/templates/list.html Download file

th:each repeats a row, th:if shows an empty state, and th:text escapes user content. @{...} builds context-aware URLs. The delete operation is a POST form rather than a link, with the automatic CSRF token. Thymeleaf executes on the server; the browser receives ordinary HTML.

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 Download file

th:object selects the form bean; *{title} selects one of its properties. th:field generates name, value, and checkbox binding support. th:errors prints server validation errors. The controller supplies action for create versus update, allowing one reusable view.

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>

src/main/resources/static/style.css Download file

Static assets are served from static/ without a controller. The template uses /style.css, not /static/style.css.

Source
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}

src/test/java/com/example/todo/TodoServiceTest.java Download file

These tests exercise useful service behavior: an unknown update must not insert data, and saved titles are trimmed. They use mocks and never connect to Atlas.

Java
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());
    }
}

4. Understand the routes and form lifecycle

Method / routeController actionResult
GET /Redirect to list302 to /todos
GET /todosLoad all documentslist.html
GET /todos/newSupply empty TodoFormform.html
POST /todosValidate and insert302 after success; form with errors otherwise
GET /todos/{id}/editLoad existing task into formform.html
POST /todos/{id}Validate and update302 to /todos
POST /todos/{id}/deleteDelete known task302 to /todos

On a successful POST, the browser follows the redirect and sends a separate GET. Refreshing that resulting GET does not repeat the insertion. On an invalid form, the controller returns the view immediately with BindingResult errors, retaining the user’s fields.

Model attributes must match the template names: todos, todoForm, and action. Returning "form" resolves templates/form.html, not a Java class or a URL. In Thymeleaf, ${...} evaluates a model expression; *{...} evaluates relative to the selected form object; @{...} constructs URLs.

A checkbox that is unchecked ordinarily submits no value. th:field supplies binding support so completed becomes false. Spring Security adds a CSRF token to forms using th:action. View the generated form in browser developer tools to see _csrf. Manually posting without the session’s token should receive 403.

5. Connect to Atlas and start

From the todo-mvc directory, set your actual Atlas connection string. The properties file selects the lab’s database separately. Never paste a real connection string into source control or a presentation.

Windows PowerShell
# Run in the SAME PowerShell terminal that will start Spring Boot.
$env:MONGODB_URI = 'mongodb+srv://YOUR_DB_USER:YOUR_ENCODED_PASSWORD@YOUR_CLUSTER.mongodb.net/?retryWrites=true&w=majority&authSource=admin'
# Replace the three placeholders with the Atlas Drivers connection details.
# Do not copy the example host literally.
mvn test
mvn spring-boot:run
macOS / Linux Bash
export MONGODB_URI='mongodb+srv://YOUR_DB_USER:YOUR_ENCODED_PASSWORD@YOUR_CLUSTER.mongodb.net/?retryWrites=true&w=majority&authSource=admin'
mvn test
mvn spring-boot:run

Tests should finish with BUILD SUCCESS. Starting the app leaves the terminal occupied; keep it running and use a separate terminal or browser for requests. Look for the “Started TodoApplication” message and Tomcat on port 8080. The first database operation also verifies Atlas access; a successful server startup alone does not prove that reads and writes work.

To stop, press Ctrl+C. To package and run without Maven’s development command:

Terminal
mvn clean package
java -jar target/todo-mvc-1.0.0.jar

The same environment variables must exist when running the JAR. Run only one instance per port. To change the port temporarily in PowerShell, set $env:SERVER_PORT = '8090' before starting. To remove a terminal variable later, use Remove-Item Env:MONGODB_URI.

Open http://localhost:8080/todos. You should see “Classroom todos” and the empty-state message. Use the application URL, not templates/list.html on disk.

6. Execute the acceptance checklist

  1. Click Add a todo. Enter “Prepare Spring class” and leave Completed unchecked. Save. Expect the list with status Pending.
  2. Add “Create Atlas cluster”. Expect two independent tasks.
  3. Edit the first task, rename it “Deliver Spring class”, and check Completed. Save. Expect Done.
  4. Submit a whitespace-only title. Browser required may allow spaces, but server @NotBlank rejects them. Expect an error and no new task.
  5. Enter a title longer than 120 characters using a crafted request or modified HTML maxlength. Expect the server validation error.
  6. Add the text <script>alert(1)</script> as a title. Expect literal text; th:text escapes HTML.
  7. Delete one task. Expect it to disappear and remain absent after refresh.
  8. Stop and restart the app. Expect remaining tasks to persist. Verify documents in Atlas.
  9. Open a deliberately invalid edit id. Expect HTTP 404.
  10. Refresh after a successful create. Expect no duplicate insertion.

The supplied unit tests verify trimming and missing-update behavior. The checklist verifies browser rendering, form binding, CSRF integration, and real persistence.

7. Explain and extend the implementation

Ask learners to trace one create request and identify the layer responsible for each operation. The controller handles binding; Bean Validation checks constraints; the service trims the title and invokes the repository; the repository performs the document write; the controller chooses the next response.

Suggested extension: add a description field with a 500-character limit. Update the document, form DTO, service, controller mapping, and both templates. Then add a completed-only filter using a derived repository query. Make learners explain each layer they changed.

The app loads the full collection for this small sample dataset. For large lists, add pagination, indexes for filter fields, and optimistic locking for concurrent edits. Trace the basic CRUD flow before adding these features.

8. Troubleshooting

SymptomCause and fix
mvn is not recognizedInstall Maven and add its bin directory to PATH. Open a new terminal. Verify mvn -version.
Cannot resolve placeholder MONGODB_URISet the environment variable in the terminal that starts the app. IDE launches need an environment variable in their Run Configuration. Spring does not automatically read a .env file.
Connection points to localhostUse spring.mongodb.uri for this Boot 4 project; a Boot 3 spring.data.mongodb.uri property is not the property used here. Check spelling and active configuration.
Authentication failed / code 18Use the Atlas database user, not your Atlas website login. Reset its password if needed. Percent-encode URI reserved characters. Verify authSource=admin.
Server selection timeoutAdd your current public IP in Atlas Network Access; verify firewall access, active cluster, hostname, and outbound network connectivity. IP addresses can change on Wi-Fi or VPN.
UnknownHostException / SRV lookup failedRe-copy the actual hostname. SRV requires DNS support; corporate DNS may block it. Use the standard connection string provided by Atlas if your environment cannot resolve SRV.
Address already in useStop the old process with Ctrl+C or override SERVER_PORT. If changing the personal API port, change React BASE and the Postman baseUrl too.
HTTP 400Check the JSON syntax, Content-Type, title, and completed. Inspect details in the JSON error. In the MVC lab, inspect the form validation message.
HTTP 404Check route and id. In the personal app, a task owned by another user also returns 404.
Browser fails but Postman worksPostman does not enforce browser CORS. Use the documented frontend origin exactly and inspect the browser Network panel.
Dependency download failsCheck proxy settings, Maven settings.xml, and repository reachability. An offline build works only after dependencies have already been downloaded.

If a template cannot be found, check src/main/resources/templates and its view name. If a form returns 403, keep th:action and Spring Security’s CSRF integration; do not disable protection to hide the problem. If CSS returns 404, use /style.css while storing the file under static/.

Next: build the JSON API →
Prepared 7 October 2026 · Java 21 · Spring Boot 4.1.1 · MongoDB Atlas
All code blocks are complete files or explicitly labelled commands. No real credentials are included.