TUTORIAL

Spring Boot REST API

Create and test a complete JSON CRUD API with validation, error handling, and an executable Postman workflow.

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 REST and the API contract

The API manages the same shared-list workflow as Manual 01, but it returns JSON. Clients could be Postman, React, a mobile app, or another server. There are no templates in this project. The application uses port 8081 and database todo_rest.

Resource URLs describe todos. HTTP methods describe the operation. Requests with a body use Content-Type: application/json. A todo response contains id, title, and completed. New ids are generated on the server. The first two labs are deliberately shared todo lists; personal ownership arrives in Manual 03.

RequestBodySuccessful response
GET /api/todosNone200 · JSON array, [] when empty
GET /api/todos/{id}None200 · one todo
POST /api/todostitle and completed201 · JSON todo + Location header
PUT /api/todos/{id}title and completed200 · updated todo
DELETE /api/todos/{id}None204 · no body

PUT is a complete replacement of editable fields, so both title and completed are required. Repeating the same PUT leaves the same state. Repeating POST can create another todo. Deleting a missing id returns 404 in this contract; the desired resource state remains absent.

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-rest directly in your IDE as a Maven project. To build from this manual instead, create a folder called todo-rest, then create every file shown in the next step at its exact path.

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

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-rest
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/TodoRequest.java
src/main/java/com/example/todo/TodoRestController.java
src/main/java/com/example/todo/ApiErrors.java
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

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-rest</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-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-rest
server.port=8081
spring.mongodb.uri=${MONGODB_URI}
spring.mongodb.database=todo_rest
spring.data.mongodb.auto-index-creation=true
server.error.include-message=never
server.error.include-stacktrace=never

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

A record is an immutable request DTO. JSON keys title and completed bind to its components. Boolean is boxed so a missing completed can be rejected by @NotNull. Clients cannot submit id. PUT requires both fields because it represents replacement of the editable state.

Java
package com.example.todo;

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

public record TodoRequest(
    @NotBlank @Size(max=120) String title,
    @NotNull Boolean completed
) {}

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

A @RestController serializes return values to JSON instead of resolving an HTML template. @RequestBody invokes JSON binding and @Valid invokes constraints. ResponseEntity controls status and headers. POST returns 201 plus Location; GET and PUT return 200; DELETE returns 204 with no response body. Missing ids return 404 through the service.

Java
package com.example.todo;

import java.net.URI;
import java.util.List;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/todos")
public class TodoRestController {
    private final TodoService service;
    public TodoRestController(TodoService service) { this.service=service; }
    @GetMapping public List<Todo> list() { return service.list(); }
    @GetMapping("/{id}") public Todo get(@PathVariable String id) { return service.get(id); }
    @PostMapping public ResponseEntity<Todo> create(@Valid @RequestBody TodoRequest request) {
        Todo todo=service.create(request.title(),request.completed());
        return ResponseEntity.created(URI.create("/api/todos/"+todo.getId())).body(todo);
    }
    @PutMapping("/{id}") public Todo update(@PathVariable String id,@Valid @RequestBody TodoRequest request) {
        return service.update(id,request.title(),request.completed());
    }
    @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable String id) {
        service.delete(id); return ResponseEntity.noContent().build();
    }
}

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

@RestControllerAdvice handles controller exceptions in one place. Field validation and JSON parse failures return 400; explicit status exceptions preserve their HTTP code. Database duplicate keys become 409 without exposing internal database messages. Security filter failures occur before controllers and use SecurityConfig’s handlers in the personal app.

Java
package com.example.todo;

import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

@RestControllerAdvice
public class ApiErrors {
    private ResponseEntity<Map<String,Object>> error(int status,String message,Object details) {
        Map<String,Object> body=new LinkedHashMap<>();
        body.put("timestamp",Instant.now().toString()); body.put("status",status);
        body.put("message",message); body.put("details",details);
        return ResponseEntity.status(status).body(body);
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<Map<String,Object>> validation(MethodArgumentNotValidException ex) {
        Map<String,String> fields=new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(e -> fields.put(e.getField(),e.getDefaultMessage()));
        return error(400,"Validation failed",fields);
    }
    @ExceptionHandler(HttpMessageNotReadableException.class)
    ResponseEntity<Map<String,Object>> malformed(HttpMessageNotReadableException ex) {
        return error(400,"Malformed JSON or incorrect field type",Map.of());
    }
    @ExceptionHandler(ResponseStatusException.class)
    ResponseEntity<Map<String,Object>> status(ResponseStatusException ex) {
        return error(ex.getStatusCode().value(),ex.getReason(),Map.of());
    }
    @ExceptionHandler(DuplicateKeyException.class)
    ResponseEntity<Map<String,Object>> duplicate(DuplicateKeyException ex) {
        return error(409,"Email already registered",Map.of());
    }
}

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. Connect to Atlas and start

From the todo-rest 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 8081. 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-rest-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:8081/api/todos. Expect [] for a fresh database. A pretty-printed browser view is optional; the actual response is JSON.

5. Configure Postman

  1. Open Postman Desktop. Click Import and choose todo-rest.postman_collection.json.
  2. Import local.postman_environment.json and select “Todo local” in the environment selector.
  3. Confirm baseUrl is http://localhost:8081. Leave todoId empty initially.
  4. The Create request sets todoId from the response using its post-response script. Follow the numbered request order.
  5. For manual requests, set Body → raw → JSON and send the exact bodies below. Do not send form-data or x-www-form-urlencoded.

Collection files include status assertions and response-field checks. Saved sample credentials in the personal collection are fictitious lab values. Do not place Atlas credentials in Postman; it only calls the backend API.

Import reference: Postman official documentation.

6. Send every CRUD request manually

6.1 List

Postman method & URL
GET {{baseUrl}}/api/todos

Click Send. Expect 200 and [] initially. Set no request body.

6.2 Create

Postman request
POST {{baseUrl}}/api/todos
Content-Type: application/json

{"title":"Learn Spring REST","completed":false}

Expect 201, a Location header such as /api/todos/67..., and a JSON object. Copy its real id into todoId if testing manually. The collection does this automatically.

Response shape · id varies
{"id":"REPLACE_WITH_REAL_ID","title":"Learn Spring REST","completed":false}

6.3 Read one

Postman request
GET {{baseUrl}}/api/todos/{{todoId}}

Expect 200 and the created todo.

6.4 Replace editable fields

Postman request
PUT {{baseUrl}}/api/todos/{{todoId}}
Content-Type: application/json

{"title":"Teach Spring REST","completed":true}

Expect 200, the same id, the new title, and completed true. Omitting completed returns 400. An unknown id returns 404 rather than inserting a new record.

6.5 Delete and prove deletion

Two separate Postman requests
DELETE {{baseUrl}}/api/todos/{{todoId}}

# Then send:
GET {{baseUrl}}/api/todos/{{todoId}}

Expect 204 with an empty body for DELETE and 404 for the subsequent GET. Do not attempt to parse a 204 response as JSON.

7. Test validation and error responses

Send the following bodies to POST /api/todos using raw JSON. Each should return 400 without inserting a record.

Three separate negative test bodies
{"title":"   ","completed":false}

{"title":"Missing status"}

{"title":"Task","completed":null}

Also send syntactically malformed JSON, such as an omitted closing brace. Expect “Malformed JSON or incorrect field type”. Use a 121-character title to check @Size. Check the response Content-Type and HTTP status instead of assuming any response body means success.

Example error shape · timestamp and messages may vary
{
  "timestamp": "2026-10-07T00:00:00Z",
  "status": 400,
  "message": "Validation failed",
  "details": {"title":"must not be blank"}
}

@RestControllerAdvice translates exceptions raised during controller binding and service execution. A validation failure is different from an unavailable database; do not catch every exception and report 400. This implementation leaves unexpected infrastructure failures as server errors without exposing a stack trace.

8. Optional terminal requests

Use curl.exe on Windows to avoid PowerShell’s older curl alias. These commands are independent of Postman. Replace REAL_ID before updating or deleting.

Windows PowerShell
curl.exe -i http://localhost:8081/api/todos

# PowerShell JSON request (works without curl quoting concerns):
$todo = Invoke-RestMethod -Method Post -Uri 'http://localhost:8081/api/todos' -ContentType 'application/json' -Body '{"title":"Terminal task","completed":false}'
$todo
Invoke-RestMethod -Method Put -Uri ('http://localhost:8081/api/todos/' + $todo.id) -ContentType 'application/json' -Body '{"title":"Terminal task updated","completed":true}'
Invoke-RestMethod -Method Delete -Uri ('http://localhost:8081/api/todos/' + $todo.id)

On macOS/Linux, use curl with single-quoted JSON:

Bash
curl -i -X POST http://localhost:8081/api/todos -H 'Content-Type: application/json' -d '{"title":"Terminal task","completed":false}'

9. Execute the full acceptance checklist

  1. Run the collection in numbered order. Expect all assertions to pass.
  2. Create two tasks, update one, delete one, and verify the list and single-resource endpoints.
  3. Check empty title, missing completed, malformed JSON, and unknown ids.
  4. Confirm Location on 201 and an empty body on 204.
  5. Restart the app and verify a remaining task persists.
  6. Inspect todo_rest.todos in Atlas. Confirm requests from the MVC lab did not modify this database.
  7. Explain why @RestController differs from @Controller and why a DTO differs from a stored document.

Exercise: add a GET /api/todos?completed=true filter and a Spring Data derived query. Then add pagination as a separate extension. Document the new contract before coding it.

10. 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 Postman sends a body but the server rejects it, verify raw JSON and Content-Type: application/json. If todoId is unresolved, run Create first and select the correct environment. If a request receives HTML, check the baseUrl and port; you may be calling the MVC app.

Next: authenticated personal todos →
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.