Skills101

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

  1. Resources are nouns in the URL: /api/courses, /api/courses/7 — not /getCourse?id=7.
  2. HTTP methods are the verbs:
MethodURLMeaningSuccess status
GET/api/courseslist200 OK
GET/api/courses/{id}read one200 OK
POST/api/coursescreate201 Created + Location header
PUT/api/courses/{id}replace / update200 OK
DELETE/api/courses/{id}delete204 No Content
  1. Status codes tell the truth: 400 bad input, 401 not logged in, 403 not allowed, 404 not found, 409 conflict, 500 server bug.
  2. Stateless: every request carries everything needed (e.g. the login token).
  3. 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();
    }
}
AnnotationJob
@RestControllerevery method's return value is written as JSON (via the Jackson library)
@RequestMapping("/api/courses")common URL prefix
@GetMapping, @PostMapping, @PutMapping, @DeleteMappingmap a method + path
@PathVariabletake {id} from the URL
@RequestBodyturn the JSON body into a Java object
ResponseEntityfull control of status code, headers and body

What happens on GET /api/courses/1: Tomcat, security filters, DispatcherServlet, CourseController, then Jackson turns the record into JSON

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:

<!-- expect: course-api/course-created.json -->
{ "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.

Practice what you learned

📝 Quiz: REST APIs5 questions · instant answers & explanations · +10 XP · counts towards your free certificateTake the quiz →