Spring-Anwendungen folgen einem bewährten Muster: Schichten. Jede Schicht hat eine klare Verantwortung. Keine Schicht weiß zu viel über die anderen. Das Ergebnis ist wartbarer, testbarer Code.
Web-Schicht: Nimmt Anfragen entgegen, validiert Eingaben, formatiert Ausgaben. Kennt HTTP, kennt keine Geschäftslogik.
Service-Schicht: Implementiert die Geschäftslogik. Kennt die Fachdomäne, kennt kein HTTP.
Daten-Schicht: Speichert und lädt Daten. Kennt die Datenbank, kennt keine Geschäftsregeln.
Das Arcade-Projekt zeigt diese Struktur:
de.digitalfrontiers.arcade/
├── web/ <- Web-Schicht
│ ├── HomeController
│ └── GameApiController
├── service/ <- Service-Schicht
│ └── GameService
└── repository/ <- Daten-Schicht
└── InMemoryGameRepository
Der Request-Fluss für “Zeige alle Spiele”:
Jede Komponente macht nur ihren Job. Der Controller weiß nicht, woher die Spiele kommen. Der Service weiß nicht, dass es einen HTTP-Request gab. Das Repository weiß nicht, wofür die Daten verwendet werden.
Testbarkeit: Jede Schicht ist isoliert testbar. Service-Tests brauchen keinen HTTP-Server. Repository-Tests brauchen keine Geschäftslogik.
Austauschbarkeit: Die Datenbank wechseln? Nur die Repository-Schicht betroffen. REST statt HTML? Nur die Web-Schicht betroffen.
Teamarbeit: Frontend-Entwickler arbeiten an Controllern. Backend-Entwickler an Services. Datenbank-Experten an Repositories. Klare Grenzen, wenig Konflikte.
Wartbarkeit: Bugs lassen sich eingrenzen. “Die Berechnung ist falsch” → Service-Schicht. “Die Anzeige ist falsch” → Web-Schicht. “Die Daten fehlen” → Daten-Schicht.
Die Abhängigkeiten zeigen immer nach unten. Nie nach oben, nie quer.
Erlaubt: - Controller → Service → Repository
Verboten: - Controller → Repository (überspringt Service) - Service → Controller (falsche Richtung) - Repository → Service (falsche Richtung)
| Aufgabe | Schicht | Beispiel |
|---|---|---|
| Request parsen | Web | @RequestParam, @PathVariable |
| Validierung (Format) | Web | @Valid, @NotNull |
| Response formatieren | Web | JSON, HTML, Status Codes |
| Geschäftsregeln | Service | “Highscore nur wenn > bisheriger” |
| Transaktionen | Service | @Transactional |
| Orchestrierung | Service | Mehrere Repos kombinieren |
| CRUD-Operationen | Repository | save(), findById(),
delete() |
| Queries | Repository | findByGameIdOrderByPoints() |
Manchmal sieht man das:
// SCHLECHT: Controller macht zu viel
@RestController
public class GameController {
@Autowired
private GameRepository repository; // Direkt das Repository!
@GetMapping("/api/games/{id}")
public Game getGame(@PathVariable String id) {
Game game = repository.findById(id).orElse(null);
if (game == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
// Geschäftslogik im Controller...
if (game.isArchived()) {
throw new ResponseStatusException(HttpStatus.GONE);
}
return game;
}
}Das funktioniert, verletzt aber die Schichtentrennung. Geschäftslogik
(isArchived()-Prüfung) gehört in den Service. Der
Controller sollte nur delegieren:
// BESSER: Controller delegiert
@RestController
public class GameController {
private final GameService gameService;
@GetMapping("/api/games/{id}")
public Game getGame(@PathVariable String id) {
return gameService.getActiveGame(id);
}
}
@Service
public class GameService {
public Game getActiveGame(String id) {
Game game = repository.findById(id)
.orElseThrow(() -> new GameNotFoundException(id));
if (game.isArchived()) {
throw new GameArchivedException(id);
}
return game;
}
}Die Logik ist jetzt wiederverwendbar. Ein anderer Controller, ein
Batch-Job, ein CLI-Tool – alle können getActiveGame()
nutzen.
Für triviale CRUD-Anwendungen wirken Schichten wie Overhead. “Ich will doch nur Daten laden!” Ja, aber:
Die Schichten sind eine Investition in die Zukunft. Sie kosten wenig und zahlen sich aus, sobald die Anwendung wächst.