# Building REST APIs with Spring Boot — Controllers, DTOs, HTTP Methods and Status Codes

Source: https://skills101.in/learn/spring-boot/spring-boot-rest-api — Skills101 free tutorial (Advanced Java & Spring Boot). Updated 2026-11-09.

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**:

| 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** |

3. **Status codes tell the truth**: 400 bad input, 401 not logged in, 403 not allowed, 404 not found, 409 conflict, 500 server bug.
4. **Stateless**: every request carries everything needed (e.g. the login token).
5. **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:

```java
// 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

```java
// 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

```java
// 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 |

![What happens on GET /api/courses/1: Tomcat, security filters, DispatcherServlet, CourseController, then Jackson turns the record into JSON](https://skills101.in/learn-img/spring-boot/request-flow.svg)

## 5. Calling the API

These are the responses from our tested project.

```text
GET /api/courses
```

<!-- expect: course-api/courses-list.json -->
```json
[
  { "id": 1, "title": "Java Full Stack", "fee": 4999, "seats": 30 },
  { "id": 2, "title": "DSA in Python", "fee": 2999, "seats": 1 }
]
```

```text
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 -->
```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):

```text
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 quiz and free certificate: https://skills101.in/learn/spring-boot/spring-boot-rest-api