Building Your First Spring Boot App: A Complete Guide to MVC Architecture and REST Controllers

Published · Updated

In a Spring Boot JSON API, Spring MVC maps an HTTP request to a controller method, converts request data into Java values, calls your application code, and converts the returned object into an HTTP response. This guide builds a tiny read-only task API so you can see that flow without a database obscuring it.

Version assumptions (updated 7 August 2026): Spring Boot 4.1.x, Java 17+ and a Maven project containing Spring Web. If you do not yet have a running project, follow Creating Your First Spring Application first.

Spring MVC is the web framework that dispatches requests to controllers. MVC can render server-side HTML views, but @RestController writes returned values to the response body instead. In this example Jackson serializes Java records to JSON because it is included by Spring Web.

Use this package layout under src/main/java/com/example/tasks:

com.example.tasks
├── TasksApplication.java
├── Task.java
├── TaskService.java
└── TaskController.java

Keep the generated TasksApplication class at the package root so component scanning covers the controller and service.

1. Define the response resource

Create Task.java:

package com.example.tasks;

public record Task(long id, String title, boolean completed) {}

This record is the JSON representation returned to a client. A response such as new Task(1, "Learn Spring MVC", false) becomes:

{"id":1,"title":"Learn Spring MVC","completed":false}

2. Put application work in a service

Create TaskService.java:

package com.example.tasks;

import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;

@Service
public class TaskService {
    private final List<Task> tasks = List.of(
            new Task(1, "Learn Spring MVC", false),
            new Task(2, "Build a REST endpoint", true)
    );

    public List<Task> findAll() {
        return tasks;
    }

    public Optional<Task> findById(long id) {
        return tasks.stream().filter(task -> task.id() == id).findFirst();
    }
}

This in-memory list is intentionally small and immutable. It demonstrates the service boundary; it is not a persistence pattern.

3. Map HTTP requests in the controller

Create TaskController.java:

package com.example.tasks;

import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/tasks")
public class TaskController {
    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @GetMapping
    public List<Task> list() {
        return taskService.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Task> get(@PathVariable long id) {
        return taskService.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }
}

The class-level @RequestMapping supplies the common path. @GetMapping narrows each method to HTTP GET. @PathVariable converts the {id} path segment to a long. ResponseEntity lets the single-item endpoint explicitly choose either 200 OK with a body or 404 Not Found without one.

4. Run and call the API

./mvnw spring-boot:run
curl -i http://localhost:8080/api/tasks
curl -i http://localhost:8080/api/tasks/1
curl -i http://localhost:8080/api/tasks/99

The first two requests return 200. The missing id returns 404.

Trace one request

For GET /api/tasks/1:

  1. The embedded server accepts the HTTP request.
  2. Spring MVC’s DispatcherServlet selects TaskController.get(...) from the registered mapping.
  3. It converts the path text 1 into the long argument.
  4. The controller delegates the lookup to TaskService.
  5. The controller builds the appropriate ResponseEntity.
  6. An HTTP message converter uses Jackson to write the Task as JSON.

The controller owns HTTP translation; the service owns the use-case logic. That separation is useful, but “one layer per noun” is not a goal. Introduce a layer when it gives a responsibility a clear home or protects a meaningful boundary.

What this first app deliberately omits

It does not yet accept untrusted input, mutate data, authenticate callers, or talk to a database. Those require additional decisions and tests. The advanced Spring Boot MVC and REST guide adds request DTO validation, creation status codes, and consistent error responses. Review IoC and dependency injection if the controller constructor is not yet clear.

Primary references

Layered service work is one foundation for the problem-solving scope described in the Forward-Deployed Engineer program.