Spring Boot: API REST, controller, serviciu și DTO
Construiește un API REST Spring Boot pentru sarcini: cereri GET și POST, DTO-uri, serviciu în memorie, răspunsuri 201, Location și tratarea unui 404.
În acest material
Extindem aplicația cu o colecție de sarcini. Clientul poate crea o sarcină, poate cere lista și poate citi o sarcină după ID. Mai întâi folosim memorie locală, ca să înțelegem contractul HTTP fără să amestecăm și persistența.
Punctul de plecare
Continuă proiectul din prima aplicație Spring Boot, cu Java 21 și Boot 4.1.1. Păstrează POM-ul, configurația și clasele existente. Toate fișierele Java din această lecție intră în src/main/java/ro/algo25/tasks/.
În această etapă presupunem cereri cu un titlu text nenul. Validarea completă a cererilor apare în articolul următor. Aplicația ascultă numai local și pierde datele la restart. Nu o descriem drept un serviciu cu stocare durabilă sau acces pentru mai mulți utilizatori.
Definește datele cererii și răspunsului
În CreateTask.java declarăm ce acceptă operația de creare:
package ro.algo25.tasks;
public record CreateTask(String title) {}
În TaskView.java declarăm forma răspunsului:
package ro.algo25.tasks;
public record TaskView(long id, String title, boolean completed) {}
Clientul trimite titlul, iar serverul alege ID-ul și starea inițială. Dacă am primi direct toate câmpurile unui obiect intern, am putea permite accidental clientului să stabilească valori pe care nu le deține. Un DTO este un contract de transfer, nu o copie obligatorie a fiecărui câmp din modelul de stocare.
Serviciul deține operațiile
Creează TaskMissing.java:
package ro.algo25.tasks;
public class TaskMissing extends RuntimeException {
public TaskMissing(long id) {
super("Task not found: " + id);
}
}
Creează TaskService.java:
package ro.algo25.tasks;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
@Service
public class TaskService {
private final Map<Long, TaskView> tasks = new LinkedHashMap<>();
private long nextId = 1;
public synchronized List<TaskView> list() {
return new ArrayList<>(tasks.values());
}
public synchronized TaskView get(long id) {
TaskView task = tasks.get(id);
if (task == null) throw new TaskMissing(id);
return task;
}
public synchronized TaskView create(String title) {
TaskView task = new TaskView(nextId++, title, false);
tasks.put(task.id(), task);
return task;
}
}
Metodele sunt sincronizate deoarece obiectul serviciu este partajat între cereri, iar LinkedHashMap nu gestionează singur modificările concurente. Întreaga operație de alocare a ID-ului și inserare este protejată de același monitor. Este o soluție didactică pentru o singură instanță, nu pentru mai multe servere.
Lista returnată este o copie a colecției de valori, deci apelantul nu primește colecția internă pentru a o modifica direct. ID-urile cresc local, iar ordinea listei este ordinea inserării. După restart toate aceste date dispar.
Controllerul traduce contractul HTTP
Creează TaskController.java:
package ro.algo25.tasks;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) {
this.service = service;
}
@GetMapping
public List<TaskView> list() {
return service.list();
}
@GetMapping("/{id}")
public TaskView get(@PathVariable long id) {
return service.get(id);
}
@PostMapping
public ResponseEntity<TaskView> create(@RequestBody CreateTask request) {
TaskView created = service.create(request.title());
return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
}
}
GET /api/tasks întoarce 200 și o listă, inclusiv [] când nu există sarcini. POST /api/tasks întoarce 201, reprezentarea creată și antetul Location. Clientul trebuie să folosească ID-ul primit, nu să presupună că va fi mereu unu. GET /api/tasks/{id} citește o singură resursă.
Pentru a transforma absența în 404, creează ApiErrors.java:
package ro.algo25.tasks;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiErrors {
@ExceptionHandler(TaskMissing.class)
public ProblemDetail missing(TaskMissing exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Sarcina nu există.");
}
}
Excepția de business nu obligă serviciul să cunoască HTTP. Componenta de tratare a erorilor decide reprezentarea publică. Nu expunem mesajele interne ale tuturor excepțiilor către client.
Urmărește un flux complet
Repornește aplicația și trimite:
curl -i http://127.0.0.1:8080/api/tasks
curl -i -X POST http://127.0.0.1:8080/api/tasks -H 'Content-Type: application/json' -d '{"title":"Invata Spring"}'
Din răspunsul POST preia Location și cere acea cale. Vei primi aceeași sarcină. Cere apoi un ID care nu apare în listă și verifică 404. Codul de stare, antetele și corpul răspunsului sunt părți distincte ale contractului.
Exersează
- Creează două sarcini cu același titlu. Contractul permite două resurse distincte, cu ID-uri diferite.
- Repornește aplicația. Lista devine goală, deoarece implementarea curentă stochează numai în memorie.
- Explică de ce nu creăm o resursă prin GET. O cerere de citire nu trebuie să aibă acest efect de business.
Verifică înainte să continui
Poți continua dacă urmărești cererea de la JSON la serviciu și înapoi și distingi lista goală de resursa absentă. Urmează validare și erori HTTP, unde eliminăm presupunerea că toate titlurile sunt valide.
Întrebări frecvente
Lista goală trebuie să întoarcă 404?
Nu în contractul nostru. Colecția există și conține zero elemente, deci răspunsul este 200 cu o listă goală.
De ce nu trimite clientul ID-ul?
În acest API, serverul deține generarea identității. Alte modele sunt posibile, dar trebuie definite explicit.
synchronized face aplicația distribuită sigură?
Nu. Protejează accesul la același obiect din aceeași JVM. Nu coordonează două instanțe independente ale aplicației.
O clasă DTO trebuie să fie record?
Nu. Recordul este o formă concisă potrivită acestui contract. Poți folosi și o clasă obișnuită.
Putem păstra această implementare ca bază de date?
Nu pentru date care trebuie păstrate după restart. O vom înlocui cu un repository în etapa de persistență.
Surse
Controller REST și răspunsuri și gestionarea erorilor, consultate la 13 octombrie 2026.