Zum Hauptinhalt springen

AB 04 – Die Service-Schicht

Die Situation

Der Katalog läuft. Dann kommt eine Rückmeldung aus dem Einkauf:

„Wir haben aus Versehen einen Lieferanten gelöscht, der noch achtzig Artikel im Sortiment hatte. Was passiert da eigentlich?"

Gute Frage. Und daneben stehen die anderen, die bisher niemand gestellt hat: Was, wenn ein Name leer ist? Wenn ein Preis negativ ist? Wenn jemand einen Artikel bei einem Lieferanten anlegt, den es gar nicht gibt?

Keine dieser Fragen hat mit HTTP zu tun. Und keine hat mit der Datenbank zu tun. Sie brauchen einen eigenen Platz.

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • begründen, warum zwischen Controller und Repository eine dritte Schicht gehört
  • eine Service-Schicht aufbauen und Fachlichkeit dorthin verlagern
  • eine fachliche Regel als eigene Ausnahme formulieren
  • die Statuscodes 404, 409 und 400 auseinanderhalten und begründen
  • Eingaben mit Bean Validation prüfen lassen statt von Hand
  • eine Fehlerantwort so gestalten, dass sie benennt, welches Feld falsch war

Warum eine dritte Schicht?

Sieh dir an, was dein SupplierController inzwischen tut: aus zwei Repositories holen, den Mapper rufen, Artikel zählen, Adressen bauen, Statuscodes setzen. Das ist eine Menge — und das meiste davon hat mit HTTP nichts zu tun.

Welche Aufgabe in welche Schicht gehört, steht im Infoblatt DTOs und Schichten.

AnfrageControllernimmt entgegen, reicht weiter, antwortet mit einem Statuscodeweiß nichts von SQL · kennt kein RepositoryServiceprüft die Regeln, entscheidet, übersetzt zwischen Entität und DTOweiß nichts von HTTP · kennt keinen StatuscodeRepositoryliest und schreibt — sonst nichtsweiß nichts von Regeln · kennt kein DTO

Die dritte Zeile jeder Schicht ist die wichtigste: Sie sagt, was die Schicht nicht weiß. Genau daraus entsteht der Nutzen.

  • Der Service kennt keinen Statuscode. Deshalb lässt er sich prüfen, ohne dass ein Server läuft — und deshalb ließe sich dieselbe Fachlichkeit später auch von einer Kommandozeile aus benutzen.
  • Der Controller kennt kein SQL. Deshalb ändert sich an ihm nichts, wenn eine Abfrage umgebaut wird.
Wenn du Tutorial 03 gemacht hast

Dann ist dir die Service-Schicht vertraut — dort ist sie entstanden, weil sich der Controller nicht testen ließ. Hier entsteht sie aus einem anderen Grund: weil es eine fachliche Regel gibt, für die es keinen Ort gibt. Zwei Wege, dasselbe Ziel.

Aufgaben

Aufgabe 1 – Der Lieferanten-Service

src/main/java/de/szut/webshop/supplier/SupplierService.java
@Service
public class SupplierService {

private final SupplierRepository supplierRepository;
private final ArticleRepository articleRepository;
private final SupplierMapper mapper;

public SupplierService(SupplierRepository supplierRepository,
ArticleRepository articleRepository,
SupplierMapper mapper) {
this.supplierRepository = supplierRepository;
this.articleRepository = articleRepository;
this.mapper = mapper;
}

@Transactional
public SupplierDto create(CreateSupplierDto dto) {
Supplier saved = supplierRepository.save(mapper.toEntity(dto));
return mapper.toDto(saved, 0);
}

@Transactional(readOnly = true)
public List<SupplierDto> findAll() {
return supplierRepository.findAll().stream()
.map(supplier -> mapper.toDto(
supplier,
articleRepository.countBySupplierId(supplier.getId())))
.toList();
}
}
AnnotationWirkung
@ServiceSpring baut die Klasse einmal und reicht sie überall hinein, wo sie im Konstruktor steht
@TransactionalAlles in der Methode gehört zu einem Vorgang: Entweder geht alles durch, oder nichts
@Transactional(readOnly = true)Dasselbe für Lesevorgänge — die Datenbank darf sich dann Arbeit sparen
Was ist eine Transaktion eigentlich?

Eine Transaktion bündelt mehrere Datenbankoperationen zu einer einzigen, unteilbaren Einheit. create speichert nicht nur den Lieferanten — durch die Kaskade aus Arbeitsblatt 01 schreibt derselbe Aufruf auch die Anschrift in die Tabelle contact. Zwei INSERT-Anweisungen für eine fachliche Handlung.

Ohne @Transactional liefe jede davon für sich: Schlägt die zweite fehl, bliebe die erste stehen — ein Lieferant ohne Anschrift, ein Zustand, den das Klassendiagramm gar nicht vorsieht. Mit @Transactional gilt: Erst wenn die Methode ohne Fehler zu Ende läuft, werden alle Änderungen gemeinsam geschrieben (Commit). Wirft irgendetwas dazwischen eine Ausnahme, macht die Datenbank alles bisher Geschriebene wieder rückgängig (Rollback) — als wäre nichts passiert.

src/main/java/de/szut/webshop/supplier/SupplierController.java
@RestController
@RequestMapping("/api/v1/suppliers")
public class SupplierController {

private final SupplierService service;

public SupplierController(SupplierService service) {
this.service = service;
}

@PostMapping
public ResponseEntity<SupplierDto> createSupplier(@RequestBody CreateSupplierDto dto) {
SupplierDto created = service.create(dto);

URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();

return ResponseEntity.created(location).body(created);
}

@GetMapping
public ResponseEntity<List<SupplierDto>> findAllSuppliers() {
return ResponseEntity.ok(service.findAll());
}
}

Der Controller ist wieder das, was er sein soll: entgegennehmen, weiterreichen, antworten. Die beiden übrigen Endpunkte fehlen noch — sie kommen in den nächsten beiden Aufgaben dazu, sobald es die passenden Ausnahmen gibt.

Aufgabe 2 – Wenn etwas nicht da ist

Bisher hat der Controller entschieden, was ein unbekannter Lieferant bedeutet — mit einer ResponseStatusException, die den Statuscode gleich mitbringt. Das war die falsche Schicht: „gibt es nicht" ist eine fachliche Aussage, 404 ist erst ihre Entsprechung in HTTP. Wer beides in einer Zeile verbindet, kann die fachliche Aussage nirgendwo sonst mehr benutzen — etwa in einem Auftrag, der nachts läuft und kein HTTP kennt.

src/main/java/de/szut/webshop/supplier/SupplierNotFoundException.java
public class SupplierNotFoundException extends RuntimeException {

public SupplierNotFoundException(Long id) {
super("Es gibt keinen Lieferanten mit der Kennung " + id + ".");
}
}
@Transactional(readOnly = true)
public SupplierDto findById(Long id) {
Supplier supplier = supplierRepository.findById(id)
.orElseThrow(() -> new SupplierNotFoundException(id));
return mapper.toDto(supplier, articleRepository.countBySupplierId(id));
}

/**
* Liefert die Entitaet - nur fuer andere Services gedacht, nicht fuer
* den Controller. Nach aussen geht ausschliesslich ein DTO.
*/
@Transactional(readOnly = true)
public Supplier getEntity(Long id) {
return supplierRepository.findById(id)
.orElseThrow(() -> new SupplierNotFoundException(id));
}
@GetMapping("/{id}")
public ResponseEntity<SupplierDto> findSupplierById(@PathVariable Long id) {
return ResponseEntity.ok(service.findById(id));
}

Aufgabe 3 – Das Löschen

Jetzt die Frage aus dem Einkauf. Baue den Endpunkt für das Löschen.

@Transactional
public void deleteById(Long id) {
Supplier supplier = supplierRepository.findById(id)
.orElseThrow(() -> new SupplierNotFoundException(id));
supplierRepository.delete(supplier);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteSupplier(@PathVariable Long id) {
service.deleteById(id);
return ResponseEntity.noContent().build();
}
requests.http
### Einen Lieferanten löschen
DELETE http://localhost:8080/api/v1/suppliers/1

Es kommt eine 500. In der Konsole steht eine DataIntegrityViolationException, und ganz unten in der Ursachenkette die Meldung der Datenbank: Der Fremdschlüssel article.supplier_id verbietet es. Das ist referentielle Integrität: Die Datenbank stellt sicher, dass jeder Fremdschlüssel auf eine tatsächlich existierende Zeile zeigt. Würde der Lieferant verschwinden, während seine Artikel noch über supplier_id auf ihn zeigen, blieben sie herrenlos zurück — genau das verhindert die Datenbank, indem sie das Löschen ablehnt.

Das ist einerseits beruhigend — die Daten bleiben heil, der Fremdschlüssel aus der Maklerdatenbank tut genau seine Arbeit. Andererseits ist die Antwort unbrauchbar:

  • 500 heißt „Serverfehler". Der Client hat aber nichts falsch gemacht — er hat nur etwas verlangt, das im Moment nicht geht.
  • Die Meldung nennt eine Fremdschlüsselbedingung. Damit kann im Einkauf niemand etwas anfangen.

Was hier der richtige Statuscode ist

Was ist der Fall?Dein Statuscode
Ein Pflichtfeld im Rumpf ist leer
Die angefragte Kennung gibt es nicht
Die Anfrage ist richtig, passt aber nicht zum aktuellen Zustand der Daten
Im Server ist etwas kaputt gegangen
Erfolgreich gelöscht, es gibt nichts zurückzugeben
0 von 5 ausgefüllt

Der Löschversuch ist der dritte Fall. Die Anfrage war richtig formuliert, die Kennung existiert — es geht nur gerade nicht, weil noch Artikel daranhängen. Der Einkauf müsste erst die Artikel umhängen oder löschen. Das ist 409 Conflict.

src/main/java/de/szut/webshop/supplier/SupplierHasArticlesException.java
public class SupplierHasArticlesException extends RuntimeException {

public SupplierHasArticlesException(Long id, long articleCount) {
super("Lieferant " + id + " hat noch " + articleCount
+ " Artikel im Sortiment und kann nicht gelöscht werden.");
}
}
in SupplierService
@Transactional
public void deleteById(Long id) {
Supplier supplier = supplierRepository.findById(id)
.orElseThrow(() -> new SupplierNotFoundException(id));

long articleCount = articleRepository.countBySupplierId(id);
if (articleCount > 0) {
throw new SupplierHasArticlesException(id, articleCount);
}

supplierRepository.delete(supplier);
}
Die Datenbank hätte es doch auch verhindert — wozu die Prüfung?

Beide sind nötig, und sie haben verschiedene Aufgaben.

Der Fremdschlüssel ist die letzte Verteidigungslinie. Er gilt für jeden Zugriff, auch für den aus einem anderen Programm oder von Hand über das Datenbankfenster. Er darf nie weg.

Die Prüfung im Service ist die erste. Sie kennt den fachlichen Grund und kann ihn sagen. Der Fremdschlüssel kennt nur Spalten.

Man baut also nicht das eine statt des anderen, sondern das eine vor dem anderen.

Aufgabe 4 – Ausnahmen in Statuscodes übersetzen

Der Service wirft jetzt zwei Ausnahmen und kennt weiterhin keinen einzigen Statuscode. Die Übersetzung in Statuscode und Fehlermeldung passiert zentral, an einer eigenen Stelle.

src/main/java/de/szut/webshop/common/ApiExceptionHandler.java
package de.szut.webshop.common;

import java.util.LinkedHashMap;
import java.util.Map;

import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import de.szut.webshop.supplier.SupplierHasArticlesException;
import de.szut.webshop.supplier.SupplierNotFoundException;

@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiExceptionHandler {

@ExceptionHandler(SupplierNotFoundException.class)
public ProblemDetail handleNotFound(SupplierNotFoundException ex) {
ProblemDetail problem =
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Nicht gefunden");
return problem;
}

@ExceptionHandler(SupplierHasArticlesException.class)
public ProblemDetail handleConflict(SupplierHasArticlesException ex) {
ProblemDetail problem =
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Löschen nicht möglich");
return problem;
}
}

@RestControllerAdvice gilt für alle Controller der Anwendung — nicht nur für SupplierController. Egal, in welcher Schicht eine Ausnahme geworfen wird — Service, Repository, irgendein verschachtelter Methodenaufruf —, sie wird am Ende hier aufgefangen, bevor die Antwort den Client erreicht.

@ExceptionHandler(SupplierNotFoundException.class) ist die Zuordnung dahinter: Fliegt irgendwo eine SupplierNotFoundException, ruft Spring genau diese Methode auf — handleNotFound — und verwendet ihren Rückgabewert als Antwort. Für SupplierHasArticlesException gilt dasselbe mit handleConflict. Eine Methode je Ausnahmetyp, zentral für die ganze Anwendung.

ProblemDetail ist das Standardformat für Fehlerantworten nach RFC 9457 — dasselbe, das Spring von sich aus liefert.

@Order ist hier keine Verzierung

In deiner application.properties steht seit Arbeitsblatt 01 spring.mvc.problemdetails.enabled=true. Damit bringt Spring einen eigenen Ausnahmebehandler mit, und der hat Vorrang.

Für die beiden Ausnahmen oben ist das gleichgültig — Spring kennt sie nicht. In Aufgabe 5 kommt aber eine Methode dazu, für die Spring sich zuständig fühlt. Ohne @Order(Ordered.HIGHEST_PRECEDENCE) käme deine Methode dann nie an die Reihe, und du würdest lange suchen, warum deine Fehlermeldung nicht erscheint.

{
"detail": "Lieferant 1 hat noch 1 Artikel im Sortiment und kann nicht gelöscht werden.",
"instance": "/api/v1/suppliers/1",
"status": 409,
"title": "Löschen nicht möglich"
}

Aufgabe 5 – Eingaben prüfen lassen

Bleiben die Felder im Rumpf. Ein leerer Name, ein negativer Preis, eine fehlende Anschrift — das ließe sich alles von Hand abfragen. Für so etwas gibt es aber eine fertige Lösung: Bean Validation. Die Abhängigkeit hast du in Arbeitsblatt 01 schon mitgewählt.

Die Regeln stehen als Annotationen an den Eingabe-DTOs — dort, wo die Daten hereinkommen.

src/main/java/de/szut/webshop/contact/CreateContactDto.java
public record CreateContactDto(

@NotBlank(message = "Die Straße darf nicht leer sein.")
String street,

@NotBlank(message = "Die Postleitzahl darf nicht leer sein.")
String postcode,

@NotBlank(message = "Der Ort darf nicht leer sein.")
String city,

String phone) {
}
src/main/java/de/szut/webshop/supplier/CreateSupplierDto.java
public record CreateSupplierDto(

@NotBlank(message = "Der Name darf nicht leer sein.")
String name,

@NotNull(message = "Zu einem Lieferanten gehört eine Anschrift.")
@Valid
CreateContactDto contact) {
}
src/main/java/de/szut/webshop/article/CreateArticleDto.java
public record CreateArticleDto(

@NotBlank(message = "Die Bezeichnung darf nicht leer sein.")
String designation,

@NotNull(message = "Ein Artikel braucht einen Preis.")
@Positive(message = "Der Preis muss größer als 0 sein.")
BigDecimal price) {
}
AnnotationPrüft
@NotNullDas Feld ist überhaupt da
@NotBlankNicht null, nicht leer, nicht nur Leerzeichen
@PositiveGrößer als null
@ValidPrüfe auch im verschachtelten Objekt weiter

Achte auf die Pakete — sie sehen sich zum Verwechseln ähnlich:

import jakarta.validation.Valid; // nur @Valid
import jakarta.validation.constraints.NotBlank; // alle Prüfregeln
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

@Valid an contact ist leicht zu übersehen und leicht falsch zu verstehen. @NotNull allein prüft nur, dass überhaupt eine Anschrift mitgeschickt wurde — ob deren Felder gefüllt sind, interessiert es nicht. Erst @Valid steigt eine Ebene tiefer.

public ResponseEntity<SupplierDto> createSupplier(
@Valid @RequestBody CreateSupplierDto dto) {
{
"detail": "Invalid request content.",
"status": 400,
"title": "Bad Request"
}

Immerhin eine 400. Aber welches Feld war es? Die Antwort verrät es nicht — bei zwei Feldern ärgerlich, bei einem Formular mit fünfzehn unbrauchbar.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(
error -> errors.put(error.getField(), error.getDefaultMessage()));

ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Die Anfrage enthält ungültige Felder.");
problem.setTitle("Ungültige Eingabe");
problem.setProperty("errors", errors);
return problem;
}
{
"detail": "Die Anfrage enthält ungültige Felder.",
"instance": "/api/v1/suppliers",
"status": 400,
"title": "Ungültige Eingabe",
"errors": {
"name": "Der Name darf nicht leer sein.",
"contact.city": "Der Ort darf nicht leer sein."
}
}

Der Pfad contact.city zeigt genau auf das Feld im verschachtelten Objekt — das ist die Arbeit von @Valid.

Wenn die Antwort weiterhin „Invalid request content." lautet

Dann fehlt @Order(Ordered.HIGHEST_PRECEDENCE) an deinem ApiExceptionHandler. Springs eigener Behandler war schneller.

Aufgabe 6 – Der Artikel-Service

Der ArticleController hat dieselbe Behandlung verdient — und dabei löst sich der offene Punkt aus Arbeitsblatt 03 auf.

src/main/java/de/szut/webshop/article/ArticleService.java
@Service
public class ArticleService {

private final ArticleRepository articleRepository;
private final SupplierService supplierService;
private final ArticleMapper mapper;

public ArticleService(ArticleRepository articleRepository,
SupplierService supplierService,
ArticleMapper mapper) {
this.articleRepository = articleRepository;
this.supplierService = supplierService;
this.mapper = mapper;
}

@Transactional
public ArticleDto create(Long supplierId, CreateArticleDto dto) {
Supplier supplier = supplierService.getEntity(supplierId);
Article saved = articleRepository.save(mapper.toEntity(dto, supplier));
return mapper.toDto(saved);
}

@Transactional(readOnly = true)
public List<ArticleDto> findAllBySupplier(Long supplierId) {
// Wirft, wenn es den Lieferanten nicht gibt - eine leere Liste waere
// hier die falsche Antwort: Sie behauptet, der Lieferant existiere.
supplierService.getEntity(supplierId);

return articleRepository.findAllBySupplierIdOrderByIdDesc(supplierId).stream()
.map(mapper::toDto)
.toList();
}
}

Der ArticleService fragt nicht selbst im SupplierRepository nach, sondern beim SupplierService. Damit gilt die Regel „unbekannter Lieferant → SupplierNotFoundException" an genau einer Stelle. Aus dem 500 von Arbeitsblatt 03 ist ohne weiteres Zutun eine 404 geworden.

Beide antworten jetzt mit 404 und der Meldung aus dem Service.

Aufgabe 7 – Die Anfragen zum Ausprobieren

Für die Testfälle brauchst du fünf Anfragen, die es noch nicht gibt.

requests.http
### Unbekannter Lieferant -> 404
GET http://localhost:8080/api/v1/suppliers/999

### Leerer Name und leerer Ort -> 400 mit Feldnamen
POST http://localhost:8080/api/v1/suppliers
Content-Type: application/json

{
"name": " ",
"contact": {
"street": "Hafenstr. 12",
"postcode": "28217",
"city": ""
}
}

### Ganz ohne Anschrift -> 400
POST http://localhost:8080/api/v1/suppliers
Content-Type: application/json

{
"name": "Ohne Anschrift GmbH"
}

### Preis 0 -> 400
POST http://localhost:8080/api/v1/suppliers/1/articles
Content-Type: application/json

{
"designation": "Kostenlos",
"price": 0
}

### Lieferant mit Artikeln loeschen -> 409
DELETE http://localhost:8080/api/v1/suppliers/1

Für den Grenzwert aus TF-07 änderst du im vierten Block 0 in 0.01 und schickst ihn erneut ab.

Aufgabe 8 – Testfälle

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Der Controller kennt kein Repository mehrUmbau abgeschlossen
  1. Beide Controller-Klassen durchsehen
Im Konstruktor steht jeweils nur noch ein Service. Kein Repository, kein Mapper.
TF-02Unbekannter Lieferant beim AbrufenEs gibt keinen Lieferanten 999
  1. GET auf /api/v1/suppliers/999
Status 404, Titel Nicht gefunden, und die Meldung nennt die Kennung 999.
TF-03Unbekannter Lieferant beim Anlegen eines ArtikelsEs gibt keinen Lieferanten 999
  1. POST auf /api/v1/suppliers/999/articles
Status 404 — nicht mehr 500 wie in Arbeitsblatt 03.
TF-04Löschen mit Artikeln wird verweigertLieferant 1 hat mindestens einen Artikel
  1. DELETE auf /api/v1/suppliers/1
Status 409. Die Meldung nennt die Anzahl der Artikel. In der Konsole steht keine DataIntegrityViolationException — es wurde gar nicht erst gelöscht.
TF-05Löschen ohne Artikel — die gefüllte RauteEin Lieferant ohne Artikel wurde angelegt
  1. DELETE auf diesen Lieferanten
  2. Im Datenbankfenster die Tabelle contact öffnen
Status 204 ohne Rumpf. Die zugehörige Zeile in contact ist verschwunden — ohne dass jemand sie gelöscht hat.
TF-06Leeres PflichtfeldAnwendung läuft
  1. POST eines Lieferanten mit name = " " und leerem Ort
Status 400, und unter errors stehen beide Felder — darunter contact.city.
TF-07Grenzwert beim PreisLieferant 1 existiert
  1. Artikel mit price = 0 anlegen
  2. Artikel mit price = 0.01 anlegen
0 ergibt 400, 0.01 ergibt 201. @Positive heißt echt größer als null.
TF-08Fehlende AnschriftAnwendung läuft
  1. POST eines Lieferanten ganz ohne Feld contact
Status 400 mit der Meldung zu contact.

Zusatzaufgabe – Der Endpunkt, auf den Location zeigt

Fünf Minuten, und eine Unsauberkeit ist weg

Beim Anlegen eines Artikels antwortet der Server mit einer Location-Kopfzeile wie …/api/v1/suppliers/1/articles/5 — und dort ist nichts. Ein Location, das ins Leere zeigt, ist eine falsche Auskunft.

Die zweite Hälfte ist die interessantere: Was soll passieren, wenn es den Artikel gibt, er aber bei einem anderen Lieferanten steht? Begründe deine Wahl zwischen 404 und 403.

Zusatzaufgabe – Blättern im Sortiment

Für alle, die schneller sind

Ein Lieferant mit achthundert Artikeln liefert im Moment achthundert Artikel auf einmal. Im Gästebuch hast du das gelöst.

Überlege dabei: Welche Methode im Repository brauchst du, und wie heißt sie? Der Rest folgt dem Muster aus Tutorial 02.

Lösungsweg der Zusatzaufgabe

Im Repository kommt eine zweite Methode dazu — der Name ist derselbe, nur die Rückgabe und ein Parameter ändern sich. Die Sortierung steckt jetzt im Pageable und gehört deshalb nicht mehr in den Namen:

Page<Article> findAllBySupplierId(Long supplierId, Pageable pageable);

Im Service:

@Transactional(readOnly = true)
public Page<ArticleDto> findAllBySupplier(Long supplierId, Pageable pageable) {
supplierService.getEntity(supplierId);
return articleRepository.findAllBySupplierId(supplierId, pageable)
.map(mapper::toDto);
}

Page.map gibt es fertig — die Seitenangaben bleiben dabei erhalten, nur der Inhalt wird übersetzt.

Im Controller:

@GetMapping
public ResponseEntity<Page<ArticleDto>> findArticlesOfSupplier(
@PathVariable Long supplierId,
@PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC)
Pageable pageable) {
return ResponseEntity.ok(service.findAllBySupplier(supplierId, pageable));
}

Musterlösung

Hier steht der vollständige Quelltext der Anwendung nach allen vier Arbeitsblättern: die drei Entitäten, beide Repositories, alle DTOs, beide Mapper, beide Services, die Ausnahmen, beide Controller, der ApiExceptionHandler, die application.properties und die docker-compose.yml.

Erst selbst versuchen

Eine Lösung zu lesen fühlt sich an wie Verstehen, ist aber keines. Wer sie aufschlägt, bevor er selbst gescheitert ist, nimmt sich genau den Teil weg, an dem man etwas lernt.

Sinnvoll ist sie in zwei Fällen: nach der eigenen Lösung zum Vergleichen — oder wenn du an einer Stelle wirklich nicht weiterkommst. Dann sieh dir nur diese eine Datei an und arbeite danach selbst weiter.

Musterlösung

Das Passwort bekommst du von deiner Lehrkraft.