Advanced Java & Spring Boot · Lesson 5 of 16 · Updated ·
Building REST APIs with Spring Boot — Controllers, DTOs, HTTP Methods and Status Codes
A REST API lets any client — a React website, an Android app, another server — work with your data over HTTP using JSON. It's the most common thing a Java backend developer builds.
1. REST in five rules
- Resources are nouns in the URL:
/api/courses,/api/courses/7— not/getCourse?id=7. - HTTP methods are the verbs:
| Method | URL | Meaning | Success status |
|---|---|---|---|
| GET | /api/courses | list | 200 OK |
| GET | /api/courses/{id} | read one | 200 OK |
| POST | /api/courses | create | 201 Created + Location header |
| PUT | /api/courses/{id} | replace / update | 200 OK |
| DELETE | /api/courses/{id} | delete | 204 No Content |
- Status codes tell the truth: 400 bad input, 401 not logged in, 403 not allowed, 404 not found, 409 conflict, 500 server bug.
- Stateless: every request carries everything needed (e.g. the login token).
- JSON in and out.
GET, PUT and DELETE are idempotent (repeating them has the same effect); POST is not (two POSTs create two courses).
2. DTOs: what goes over the wire
We never send JPA entities directly. DTOs (data transfer objects) — Java records here — define exactly what the API accepts and returns:
// File: course-api/src/main/java/com/skills101/courses/course/CourseResponse.java
package com.skills101.courses.course;
// What the API sends back — a DTO, so the entity's internals never leak out
public record CourseResponse(Long id, String title, int fee, int seats) {
public static CourseResponse from(Course c) {
return new CourseResponse(c.getId(), c.getTitle(), c.getFee(), c.getSeats());
}
}
(The request DTO, CourseRequest, gets validation rules in the next lesson.)
3. The service
// File: course-api/src/main/java/com/skills101/courses/course/CourseService.java
package com.skills101.courses.course;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CourseService {
private final CourseRepository repository;
public CourseService(CourseRepository repository) { // constructor injection
this.repository = repository;
}
@Transactional(readOnly = true)
public List<CourseResponse> findAll() {
return repository.findAll().stream().map(CourseResponse::from).toList();
}
@Transactional(readOnly = true)
public CourseResponse findById(Long id) {
return repository.findById(id)
.map(CourseResponse::from)
.orElseThrow(() -> new CourseNotFoundException(id));
}
@Transactional
public CourseResponse create(CourseRequest request) {
Course saved = repository.save(new Course(request.title(), request.fee(), request.seats()));
return CourseResponse.from(saved);
}
@Transactional
public CourseResponse update(Long id, CourseRequest request) {
Course course = repository.findById(id).orElseThrow(() -> new CourseNotFoundException(id));
course.update(request.title(), request.fee(), request.seats()); // saved automatically at commit
return CourseResponse.from(course);
}
@Transactional
public void delete(Long id) {
if (!repository.existsById(id)) {
throw new CourseNotFoundException(id);
}
repository.deleteById(id);
}
}
4. The controller
// File: course-api/src/main/java/com/skills101/courses/course/CourseController.java
package com.skills101.courses.course;
import jakarta.validation.Valid;
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/courses")
public class CourseController {
private final CourseService service;
public CourseController(CourseService service) {
this.service = service;
}
@GetMapping
public List<CourseResponse> all() {
return service.findAll();
}
@GetMapping("/{id}")
public CourseResponse one(@PathVariable Long id) {
return service.findById(id);
}
@PostMapping
public ResponseEntity<CourseResponse> create(@Valid @RequestBody CourseRequest request) {
CourseResponse created = service.create(request);
return ResponseEntity.created(URI.create("/api/courses/" + created.id())).body(created);
}
@PutMapping("/{id}")
public CourseResponse update(@PathVariable Long id, @Valid @RequestBody CourseRequest request) {
return service.update(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
| Annotation | Job |
|---|---|
@RestController | every method's return value is written as JSON (via the Jackson library) |
@RequestMapping("/api/courses") | common URL prefix |
@GetMapping, @PostMapping, @PutMapping, @DeleteMapping | map a method + path |
@PathVariable | take {id} from the URL |
@RequestBody | turn the JSON body into a Java object |
ResponseEntity | full control of status code, headers and body |
5. Calling the API
These are the responses from our tested project.
GET /api/courses
<!-- expect: course-api/courses-list.json -->
[
{ "id": 1, "title": "Java Full Stack", "fee": 4999, "seats": 30 },
{ "id": 2, "title": "DSA in Python", "fee": 2999, "seats": 1 }
]
POST /api/courses (as an admin — security comes in lesson 9)
Content-Type: application/json
{ "title": "Spring Boot Microservices", "fee": 5999, "seats": 40 }
Response: 201 Created, header Location: /api/courses/3, body:
{ "id": 3, "title": "Spring Boot Microservices", "fee": 5999, "seats": 40 }
Try it yourself with curl, Postman or the built-in Swagger UI (lesson 11):
curl http://localhost:8081/api/courses
curl http://localhost:8081/api/courses/1
6. Query parameters
Filters and paging use query parameters, not new paths: GET /api/courses?maxFee=3000&page=0&size=20 with @RequestParam(defaultValue = "0") int page. Spring Data can return a Page<…> for you.
Common mistakes
- Verbs in URLs (
/api/createCourse) — use POST on/api/courses. - Returning 200 for everything, even errors.
- Returning entities: you leak internal fields and can hit infinite JSON loops with relationships.
- Forgetting
@RequestBody— the object arrives empty.
Quick recap
- Nouns in URLs, HTTP methods as verbs, honest status codes, JSON, stateless.
@RestController+ mapping annotations;@PathVariable,@RequestBody,@RequestParam.- Records as DTOs;
ResponseEntity.created(...)gives 201 + Location. - Controller → service → repository.