Skip to content
elephantoo

Next step: intro to Spring Boot

Lesson 43 of 43 16 min read

What Spring Boot is, dependency injection, creating a project and building your first REST API.


Congratulations: you've reached the final lesson! You can now write solid, modern Java. Most professional Java developers use that skill to build back-end services: the web APIs behind apps and websites. The most popular framework for this is Spring Boot. This lesson explains what it is and walks you through building and testing a small REST API. Everything here was built and run with Spring Boot 4.1 on Java 21.

What Spring and Spring Boot are#

  • Spring Framework is a large, mature library for building Java applications. Its core idea is dependency injection (DI): instead of objects creating the things they need with new, a container creates them and hands them over.
  • Spring Boot sits on top of Spring and removes most of the setup work:
    • Starters: one dependency (e.g. spring-boot-starter-webmvc) brings in everything for a feature, with compatible versions.
    • Auto-configuration: Boot looks at what's on the classpath and configures it with sensible defaults. If it sees Spring MVC, you get a web server and JSON support.
    • Embedded server: your app is a plain java -jar app.jar with Tomcat inside. There's no separate server to install.
    • Production features: configuration files, profiles, health checks (Actuator), metrics and logging.

Dependency injection in plain Java first

Java
// Without DI: the controller builds its own dependency (hard to swap or test)
class TaskController {
    private final TaskService service = new TaskService();
}

// With DI: the dependency is passed in
class TaskController {
    private final TaskService service;
    TaskController(TaskService service) { this.service = service; }
}

In the second version, Spring is the code that calls new TaskController(theService). Objects managed by Spring are called beans. You mark classes as beans with annotations like @Service, @Repository, @Component and @RestController, and Spring wires them together through their constructors.

Creating a project#

The standard way is the Spring Initializr at start.spring.io:

  1. Project: Maven, Language: Java, Spring Boot: the latest stable version (4.1.x at the time of writing).
  2. Group com.example, Artifact tasks, Java 21.
  3. Add the dependency Spring Web.
  4. Click Generate, unzip, and open the folder in your IDE. IntelliJ IDEA and VS Code (with the Java and Spring extensions) both have Initializr built in.

Or from a terminal:

Terminal
curl https://start.spring.io/starter.zip \
  -d type=maven-project -d language=java -d javaVersion=21 \
  -d dependencies=web -d groupId=com.example -d artifactId=tasks \
  -d packageName=com.example.tasks -o tasks.zip
unzip tasks.zip -d tasks && cd tasks

You get this layout (the Maven conventions from the build-tools lesson):

Output
tasks/
├── mvnw, mvnw.cmd, .mvn/          ← Maven Wrapper: no Maven install needed
├── pom.xml
└── src/
    ├── main/java/com/example/tasks/TasksApplication.java
    ├── main/resources/application.properties
    └── test/java/com/example/tasks/TasksApplicationTests.java

The important parts of pom.xml:

pom.xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/>
</parent>

<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-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

The parent manages the versions of hundreds of libraries, so the dependencies have no <version>. (In Spring Boot 3.x the web starter was called spring-boot-starter-web, which you'll see in many tutorials.)

The entry point#

TasksApplication.java
package com.example.tasks;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TasksApplication {
    public static void main(String[] args) {
        SpringApplication.run(TasksApplication.class, args);
    }
}

@SpringBootApplication turns on component scanning for this package and its sub-packages, plus auto-configuration. Run it:

Terminal
./mvnw spring-boot:run        # Windows: mvnw.cmd spring-boot:run
Output
 :: Spring Boot ::                (v4.1.1)
... Starting TasksApplication using Java 21 ...
... Tomcat started on port 8080 (http) with context path '/'
... Started TasksApplication in 1.4 seconds

Your first endpoint#

HelloController.java
package com.example.tasks;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello(@RequestParam(defaultValue = "world") String name) {
        return "Hello, " + name + "!";
    }
}

Restart the app and call it with a browser or curl:

Terminal
curl localhost:8080/hello
curl "localhost:8080/hello?name=Asha"
Output
Hello, world!
Hello, Asha!
  • @RestController: a bean whose methods handle HTTP requests; return values become the response body.
  • @GetMapping("/hello"): handles GET /hello. There are also @PostMapping, @PutMapping, @DeleteMapping and @PatchMapping.
  • @RequestParam: reads ?name=... from the query string.

A small REST API for tasks#

A REST API exposes resources (here: tasks) at URLs and uses HTTP methods for actions:

Method & pathActionSuccess status
GET /api/taskslist all200 OK
GET /api/tasks/{id}get one200, or 404 if missing
POST /api/taskscreate201 Created
PUT /api/tasks/{id}/donemark done200, or 404
DELETE /api/tasks/{id}delete204 No Content, or 404

The data: records

Records are perfect DTOs (data transfer objects). Spring's JSON library (Jackson) converts them to and from JSON automatically:

Task.java
package com.example.tasks;

public record Task(long id, String title, boolean done) { }
NewTask.java
package com.example.tasks;

public record NewTask(String title) { }

The business logic: a @Service

For now, we keep tasks in memory using the thread-safe collections from the concurrency module (a web server handles many requests at once):

TaskService.java
package com.example.tasks;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

import org.springframework.stereotype.Service;

@Service
public class TaskService {
    private final Map<Long, Task> tasks = new ConcurrentHashMap<>();
    private final AtomicLong nextId = new AtomicLong(1);

    public List<Task> findAll() {
        return tasks.values().stream()
                .sorted(Comparator.comparingLong(Task::id))
                .toList();
    }

    public Optional<Task> findById(long id) {
        return Optional.ofNullable(tasks.get(id));
    }

    public Task create(String title) {
        long id = nextId.getAndIncrement();
        Task task = new Task(id, title, false);
        tasks.put(id, task);
        return task;
    }

    public Optional<Task> markDone(long id) {
        return Optional.ofNullable(
                tasks.computeIfPresent(id, (k, t) -> new Task(t.id(), t.title(), true)));
    }

    public boolean delete(long id) {
        return tasks.remove(id) != null;
    }
}

Notice that this is ordinary Java: streams, Optional, records and ConcurrentHashMap. Everything you learned in this course applies directly.

The web layer: a @RestController

TaskController.java
package com.example.tasks;

import java.net.URI;
import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    public TaskController(TaskService service) {     // constructor injection
        this.service = service;
    }

    @GetMapping
    public List<Task> all() {
        return service.findAll();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Task> one(@PathVariable long id) {
        return ResponseEntity.of(service.findById(id));   // 200 with body, or 404
    }

    @PostMapping
    public ResponseEntity<Task> create(@RequestBody NewTask body) {
        if (body.title() == null || body.title().isBlank()) {
            return ResponseEntity.badRequest().build();
        }
        Task created = service.create(body.title().strip());
        return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
    }

    @PutMapping("/{id}/done")
    public ResponseEntity<Task> done(@PathVariable long id) {
        return ResponseEntity.of(service.markDone(id));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable long id) {
        return service.delete(id)
                ? ResponseEntity.noContent().build()
                : ResponseEntity.notFound().build();
    }
}
  • @RequestMapping("/api/tasks") on the class sets a common path prefix.
  • @PathVariable binds {id} from the URL; @RequestBody converts the JSON body into a NewTask.
  • ResponseEntity lets you choose the status code and headers. created(uri) gives 201 with a Location header.
  • There's no @Autowired on the constructor: with a single constructor, Spring injects automatically.

Trying it out

Terminal
curl -i -X POST localhost:8080/api/tasks \
     -H "Content-Type: application/json" -d '{"title": "Learn Spring Boot"}'
Output
HTTP/1.1 201
Location: /api/tasks/1
Content-Type: application/json

{"id":1,"title":"Learn Spring Boot","done":false}
Terminal
curl -X POST localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title": "Build a REST API"}'
curl localhost:8080/api/tasks
curl -X PUT localhost:8080/api/tasks/1/done
curl -i localhost:8080/api/tasks/42
curl -i -X DELETE localhost:8080/api/tasks/2
curl localhost:8080/api/tasks
Output
{"id":2,"title":"Build a REST API","done":false}
[{"id":1,"title":"Learn Spring Boot","done":false},{"id":2,"title":"Build a REST API","done":false}]
{"id":1,"title":"Learn Spring Boot","done":true}
HTTP/1.1 404
HTTP/1.1 204
[{"id":1,"title":"Learn Spring Boot","done":true}]

(The two -i calls also print headers, which are trimmed here.) Sending {"title": " "} returns 400 Bad Request. For real validation rules, add the Validation starter and annotate the record: record NewTask(@NotBlank String title) with @Valid @RequestBody.

Configuration: application.properties#

src/main/resources/application.properties
spring.application.name=tasks
server.port=8080
# Serve each request on a virtual thread (Java 21+)
spring.threads.virtual.enabled=true
logging.level.com.example.tasks=DEBUG

You can override any property without rebuilding: java -jar target/tasks-0.0.1-SNAPSHOT.jar --server.port=9090, or set the environment variable SERVER_PORT=9090. Profiles (application-dev.properties, application-prod.properties, activated with spring.profiles.active=prod) hold environment-specific settings such as database URLs. Never commit real passwords; read them from environment variables instead.

Testing the web layer#

The generated project already includes JUnit 5, Mockito and AssertJ (from the JUnit lesson). @WebMvcTest starts only the web layer, without a real server, and @MockitoBean replaces the service with a Mockito mock:

TaskControllerTest.java
package com.example.tasks;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.util.List;
import java.util.Optional;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(TaskController.class)
class TaskControllerTest {

    @Autowired
    MockMvc mvc;

    @MockitoBean
    TaskService service;

    @Test
    void listsTasks() throws Exception {
        when(service.findAll()).thenReturn(List.of(new Task(1, "Learn Java", true)));

        mvc.perform(get("/api/tasks"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$[0].title").value("Learn Java"))
                .andExpect(jsonPath("$[0].done").value(true));
    }

    @Test
    void unknownTaskIs404() throws Exception {
        when(service.findById(99)).thenReturn(Optional.empty());

        mvc.perform(get("/api/tasks/99"))
                .andExpect(status().isNotFound());
    }

    @Test
    void createsTask() throws Exception {
        when(service.create("Try Spring")).thenReturn(new Task(7, "Try Spring", false));

        mvc.perform(post("/api/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\": \"Try Spring\"}"))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/api/tasks/7"))
                .andExpect(jsonPath("$.id").value(7));
    }

    @Test
    void blankTitleIs400() throws Exception {
        mvc.perform(post("/api/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\": \"  \"}"))
                .andExpect(status().isBadRequest());
    }
}
Terminal
./mvnw test
Output
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.tasks.TasksApplicationTests
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.tasks.TaskControllerTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

TaskService is plain Java, so you can test it with ordinary JUnit and no Spring at all: new TaskService(). For full end-to-end tests that start the whole application, use @SpringBootTest.

Packaging and running#

Terminal
./mvnw package
java -jar target/tasks-0.0.1-SNAPSHOT.jar

The Spring Boot Maven plugin builds a single executable "fat" JAR that contains your code, all dependencies and the embedded Tomcat. You can copy it to any machine with Java 21 and run it, or put it in a Docker image (./mvnw spring-boot:build-image builds one for you).

Where to go from here#

  • Spring Data JPA + MySQL/PostgreSQL: replace the in-memory map with a database. You write an interface like interface TaskRepository extends JpaRepository<Task, Long> {}, and Spring generates the JDBC code you wrote by hand in the JDBC lesson.
  • Validation and error handling: @Valid, and @RestControllerAdvice with ProblemDetail for consistent JSON error responses.
  • Spring Security: authentication, authorisation and JWT tokens.
  • Actuator: health checks and metrics for production.
  • Testcontainers: integration tests against a real database in Docker.
  • The official guides at spring.io/guides, e.g. "Building a RESTful Web Service".

Common mistakes#

  • Putting controllers or services in a package outside the main application's package. Component scanning won't find them, so you get 404s or "No qualifying bean" errors.
  • Using field injection (@Autowired private TaskService service;) instead of constructor injection: it's harder to test and can't be final.
  • Forgetting the Content-Type: application/json header on POST requests (you'll get 415 Unsupported Media Type).
  • Returning entities with sensitive fields (like password hashes) directly. Use record DTOs that expose only what clients need.
  • Keeping mutable state in a bean with a plain HashMap. Beans are shared by all requests, so they must be thread-safe.
  • Copying Spring Boot 2.x tutorials that use javax.* imports. Since Boot 3, it's jakarta.*.

What's next#

You've finished the Java course. From your first Hello, World! you've covered the language, OOP, collections, functional programming, concurrency, the JVM, databases, build tools, testing, modern Java and now Spring Boot. The best next step is to build something: a REST API for a project you care about, with a database, tests and a README. Push it to GitHub, keep practising, and enjoy being a Java developer!

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does @SpringBootApplication do?

  2. 2.Why is constructor injection the recommended way to receive dependencies?

  3. 3.A controller method returns ResponseEntity.of(optional). What happens when the Optional is empty?

Finished reading?

Mark this lesson complete to track your progress.