Spring Boot is the most widely used Java framework for web services. It configures an embedded web server, JSON conversion, validation and much more automatically, so a REST API is a few annotated classes. Generate projects at start.spring.io.
A @RestController maps HTTP requests to methods (@GetMapping, @PostMapping). Records make clean request and response types; Jakarta Validation annotations (@NotBlank, @Min, @Max) plus @Valid reject bad input with a 400 response automatically.
Spring injects dependencies through constructors: declare a @Service and ask for it in the controller's constructor. Map your own exceptions to HTTP statuses with @ResponseStatus or an exception handler, and test the whole web layer with MockMvc.
<?xml version="1.0" encoding="UTF-8"?>
<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>
</parent>
<groupId>tz.dolese</groupId>
<artifactId>results-api</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-validation</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>
</project>package tz.dolese.results;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ResultsApplication {
public static void main(String[] args) {
SpringApplication.run(ResultsApplication.class, args);
}
}package tz.dolese.results;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.*;
record StudentRequest(@NotBlank String name, @Min(1) @Max(6) int form, List<@Min(0) @Max(100) Integer> scores) {}
record StudentResponse(long id, String name, int form, double average) {}
@ResponseStatus(HttpStatus.NOT_FOUND)
class StudentNotFoundException extends RuntimeException {
StudentNotFoundException(long id) { super("Student " + id + " not found"); }
}
@Service
class StudentService {
private final Map<Long, StudentRequest> store = new ConcurrentHashMap<>();
private final AtomicLong ids = new AtomicLong();
StudentResponse create(StudentRequest req) {
long id = ids.incrementAndGet();
store.put(id, req);
return toResponse(id, req);
}
StudentResponse get(long id) {
StudentRequest req = store.get(id);
if (req == null) throw new StudentNotFoundException(id);
return toResponse(id, req);
}
private StudentResponse toResponse(long id, StudentRequest req) {
List<Integer> scores = req.scores() == null ? List.of() : req.scores();
double avg = scores.stream().mapToInt(Integer::intValue).average().orElse(0);
return new StudentResponse(id, req.name(), req.form(), Math.round(avg * 10) / 10.0);
}
}
@RestController
@RequestMapping("/api/students")
class StudentController {
private final StudentService service;
StudentController(StudentService service) { // constructor injection
this.service = service;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
StudentResponse create(@Valid @RequestBody StudentRequest request) {
return service.create(request);
}
@GetMapping("/{id}")
StudentResponse get(@PathVariable long id) {
return service.get(id);
}
}package tz.dolese.results;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class StudentControllerTest {
@Autowired MockMvc mvc;
@Test
void createsAndFetchesStudent() throws Exception {
mvc.perform(post("/api/students").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Amina\",\"form\":4,\"scores\":[80,90]}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.average").value(85.0));
mvc.perform(get("/api/students/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Amina"));
}
@Test
void rejectsInvalidInputAndUnknownIds() throws Exception {
mvc.perform(post("/api/students").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"\",\"form\":9}"))
.andExpect(status().isBadRequest());
mvc.perform(get("/api/students/999")).andExpect(status().isNotFound());
}
}mvn test
mvn spring-boot:run
curl -X POST localhost:8080/api/students -H "Content-Type: application/json" \
-d '{"name":"Amina","form":4,"scores":[80,90]}'Key points
@RestController+ mapping annotations turn methods into endpoints.- Records + Jakarta Validation +
@Validgive typed, validated input. - Use constructor injection, map domain exceptions to statuses, and test with MockMvc.
Exercise
Add GET /api/students?form=4, PUT /api/students/{id} and DELETE /api/students/{id} with tests. Then replace the in-memory map with Spring Data JDBC or JPA backed by PostgreSQL.
Show solution
Try the exercise yourself first — then compare your approach with this one.
The service gains list(form), update and delete; the controller maps them to GET with an optional @RequestParam, PUT and DELETE. Unknown ids still raise StudentNotFoundException, which becomes a 404. The tests cover each endpoint and its error case.
// In StudentService:
List<StudentResponse> list(Integer form) {
return store.entrySet().stream()
.filter(e -> form == null || e.getValue().form() == form)
.sorted(Map.Entry.comparingByKey())
.map(e -> toResponse(e.getKey(), e.getValue()))
.toList();
}
StudentResponse update(long id, StudentRequest req) {
if (!store.containsKey(id)) throw new StudentNotFoundException(id);
store.put(id, req);
return toResponse(id, req);
}
void delete(long id) {
if (store.remove(id) == null) throw new StudentNotFoundException(id);
}
// In StudentController:
@GetMapping
List<StudentResponse> list(@RequestParam(required = false) Integer form) {
return service.list(form);
}
@PutMapping("/{id}")
StudentResponse update(@PathVariable long id, @Valid @RequestBody StudentRequest request) {
return service.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
void delete(@PathVariable long id) {
service.delete(id);
}package tz.dolese.results;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
class StudentCrudTest {
@Autowired MockMvc mvc;
void create(String json) throws Exception {
mvc.perform(post("/api/students").contentType(MediaType.APPLICATION_JSON).content(json))
.andExpect(status().isCreated());
}
@Test
void filtersByForm() throws Exception {
create("{\"name\":\"Amina\",\"form\":4}");
create("{\"name\":\"Juma\",\"form\":3}");
mvc.perform(get("/api/students").param("form", "4"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].name").value("Amina"));
}
@Test
void updatesAndDeletes() throws Exception {
create("{\"name\":\"Amina\",\"form\":4}");
mvc.perform(put("/api/students/1").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Amina Hassan\",\"form\":4,\"scores\":[90]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.average").value(90.0));
mvc.perform(delete("/api/students/1")).andExpect(status().isNoContent());
mvc.perform(delete("/api/students/1")).andExpect(status().isNotFound());
mvc.perform(put("/api/students/9").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Nobody\",\"form\":1}"))
.andExpect(status().isNotFound());
}
}