Lektion 1 von 9
Mehrere Benutzer, eine Anwendung: Schichten und Verantwortungen
Du erkennst, was eine Multi-User-Applikation ausmacht, teilst ein Backend in Controller, Service und Repository und setzt Geschäftsregeln an die richtige Stelle.
Worum es geht
Bei der Infra Kommunal AG in Wil SG klingelt jeden Montag das Telefon. Ein Turnverein will die Turnhalle für Dienstagabend, ein Chor den Gemeindesaal für Samstag. Die Gemeindekanzlei trägt alles in eine Excel-Liste ein, die auf einem Netzlaufwerk liegt. Wenn zwei Personen die Liste gleichzeitig offen haben, gewinnt, wer zuletzt speichert. Die Folge: Doppelbelegungen, verärgerte Vereine und ein Hauswart, der am Dienstag um 19 Uhr zwei Mannschaften in derselben Halle stehen hat.
Ramon ist im 3. Lehrjahr als Informatiker Applikationsentwicklung. Er soll für zwölf Gemeinden eine Webapplikation bauen: Vereine reservieren online, Hauswarte genehmigen, die Gemeindeadministration verwaltet Räume und Benutzer. Das ist eine Multi-User-Applikation, und damit stellen sich Fragen, die bei deinen bisherigen Programmen nie aufgetaucht sind:
- Wer bist du? Ohne Anmeldung weiss die Anwendung nicht, wer gerade klickt.
- Was darfst du? Ein Verein darf anfragen, aber nicht genehmigen. Ein Hauswart aus Uzwil darf nichts in Flawil ändern.
- Was passiert, wenn zwei gleichzeitig dasselbe tun? Zwei Vereine, eine Halle, derselbe Abend.
- Wie bleibt der Code beherrschbar, wenn all das dazukommt?
In diesem Kurs beantwortest du alle vier Fragen, Schritt für Schritt, am Beispiel von Ramons Raumreservation. Diese erste Lektion legt das Fundament: eine Architektur in Schichten, in der jede Regel genau einen Platz hat.
Was eine Multi-User-Applikation ausmacht
Ein Programm, das nur du auf deinem Laptop benutzt, darf vieles, was im Mehrbenutzerbetrieb gefährlich wird. Der Vergleich zeigt, was sich ändert:
| Thema | Einzelplatz-Programm | Multi-User-Applikation |
|---|---|---|
| Daten | lokal, in einer Datei | zentral in einer Datenbank, alle greifen darauf zu |
| Benutzer | eine Person | viele Personen mit unterschiedlichen Rollen |
| Gleichzeitigkeit | kommt nicht vor | gehört zum Alltag, Konflikte sind normal |
| Sicherheit | wer am Gerät sitzt, darf alles | jede Anfrage muss geprüft werden |
| Fehlerwirkung | betrifft dich | betrifft alle Benutzenden und alle Daten |
| Betrieb | Programm starten | Server, Datenbank, Identity-Provider, Deployment |
Technisch sieht Ramons Anwendung so aus:
Das Frontend im Browser schickt Anfragen an eine REST-API. Dahinter liegt das Backend, aufgeteilt in Schichten, und ganz unten die Datenbank. Für die Anmeldung kommt ein Identity-Provider wie Keycloak dazu. Wichtig ist eine Grundhaltung: Der Server vertraut dem Browser nie. Alles, was im Browser passiert, kann eine Person mit den Entwicklertools oder Postman verändern.
Ramon baut die Raumreservation als Multi-User-Applikation. Welche Aussagen gelten für Multi-User-Applikationen, aber nicht für ein Programm, das nur eine Person lokal benutzt? (mehrere Antworten)
Wähle alle zutreffenden Antworten.
Drei Schichten, drei Verantwortungen
Stell dir ein Restaurant vor. Das Servicepersonal nimmt Bestellungen entgegen und bringt die Teller. Die Küche kocht nach Rezepten und entscheidet, ob ein Gericht überhaupt möglich ist. Das Lager liefert die Zutaten. Gäste gehen nie in die Küche, und der Küche ist egal, ob die Bestellung am Tisch, per Telefon oder über eine Liefer-App kam.
Genau so teilst du ein Backend auf:
| Schicht | Aufgabe | Typische Klassen | Was hier NICHT hingehört |
|---|---|---|---|
| Controller (Präsentation) | HTTP-Anfragen annehmen, JSON in DTOs umwandeln, Statuscodes setzen | ReservationController | Geschäftsregeln, SQL |
| Service (Geschäftslogik) | Regeln prüfen, Abläufe steuern, Transaktionen | ReservationService | HTTP, JSON, Statuscodes |
| Repository (Datenzugriff) | Objekte laden und speichern | ReservationRepository | Entscheidungen, Regeln |
| Domain (Fachmodell) | fachliche Objekte mit ihren Invarianten | Reservation, Raum, Verein | Datenbank- oder HTTP-Details |
Dazu kommen DTOs (Data Transfer Objects): schlanke Klassen, die nur die Daten für die Schnittstelle enthalten. Sie sind das Thema der nächsten Lektion.
In Ramons Spring-Boot-Projekt spiegelt sich das in der Paketstruktur:
ch.infrakommunal.raumreservation
├── controller ReservationController, RaumController
├── service ReservationService, ReservationKonfliktException
├── repository ReservationRepository, RaumRepository, VereinRepository
├── domain Reservation, Raum, Verein, Gemeinde, Status
└── dto ReservationAnfrage, ReservationDtoDie wichtigste Regel heisst Abhängigkeiten zeigen nur nach unten: Der Controller kennt den Service, der Service kennt das Repository. Nie umgekehrt. Ein Repository ruft keinen Service auf, ein Service gibt keine ResponseEntity zurück.
Ordne jedem Ausschnitt aus Ramons Projekt die Schicht bzw. Klassenart zu, in die er gehört.
Dependency Injection: wer baut die Objekte zusammen?
Der Service braucht ein Repository. Er könnte es selbst erzeugen (new ReservationRepository()), aber dann wäre er fest an diese eine Implementierung gebunden. Im Test könntest du kein Ersatz-Repository einsetzen, und jede Klasse müsste wissen, wie man eine Datenbankverbindung aufbaut.
Stattdessen gibt man der Klasse ihre Abhängigkeiten von aussen über den Konstruktor. Das heisst Dependency Injection (DI). In Spring Boot übernimmt das Framework das Zusammenbauen: Klassen mit @RestController, @Service oder @Repository werden beim Start als sogenannte Beans erzeugt, und Spring übergibt jedem Konstruktor die passenden anderen Beans.
@Service
public class ReservationService {
private final ReservationRepository reservationen; // final: wird nur im Konstruktor gesetzt
// Spring ruft diesen Konstruktor auf und übergibt das Repository automatisch
public ReservationService(ReservationRepository reservationen) {
this.reservationen = reservationen;
}
}Im Unit-Test erzeugst du den Service selbst und gibst ihm ein Test-Repository mit. So testest du die Regeln in Millisekunden, ohne Datenbank.
In der Ferienplanung einer Gemeindeverwaltung gilt: «Maximal 2 Personen pro Team gleichzeitig abwesend.» Die Prüfung steht heute im REST-Controller und zusätzlich im Frontend. Nächsten Monat kommt ein nächtlicher Import aus dem HR-System dazu. Wo gehört die Regel hin?
Schritt für Schritt: Die Überschneidungsprüfung zieht um
Ramons erster Entwurf sah so aus:
@PostMapping("/api/reservationen")
public ResponseEntity<?> anfragen(@RequestBody Reservation r) {
// Regel mitten im HTTP-Code, lädt dazu ALLE Reservationen aller Gemeinden
boolean belegt = repository.findAll().stream()
.anyMatch(x -> x.getRaum().getId().equals(r.getRaum().getId())
&& x.getBeginn().isBefore(r.getEnde())
&& r.getBeginn().isBefore(x.getEnde()));
if (belegt) {
return ResponseEntity.status(409).body("Raum belegt");
}
return ResponseEntity.ok(repository.save(r));
}Sein Berufsbildner fragt: «Was passiert, wenn nächstes Jahr eine Hauswart-App dieselbe Prüfung braucht?» Ramon baut um.
Schritt 1: Die Regel präzise formulieren. Zwei Zeiträume A und B überschneiden sich genau dann, wenn beginnA < endeB und beginnB < endeA. Das Ende zählt nicht mehr zum Zeitraum. So darf ein Verein um 20:30 Uhr beginnen, wenn der vorherige um 20:30 Uhr aufhört.
| A | B | beginnA < endeB | beginnB < endeA | Überschneidung |
|---|---|---|---|---|
| 19:00 bis 21:00 | 20:00 bis 22:00 | ja | ja | ja |
| 19:00 bis 20:30 | 20:30 bis 22:00 | ja | nein (20:30 < 20:30 ist falsch) | nein |
| 18:00 bis 22:00 | 19:00 bis 20:00 | ja | ja | ja |
| 17:00 bis 18:00 | 18:30 bis 19:00 | ja | nein | nein |
Schritt 2: Das Repository fragt gezielt. Statt alles zu laden, lässt Ramon die Datenbank prüfen. Spring Data JPA erzeugt die Abfrage aus dem Methodennamen:
public interface ReservationRepository extends JpaRepository<Reservation, Long> {
// wird zu: select ... where raum_id = ? and beginn < ? and ende > ?
boolean existsByRaumIdAndBeginnLessThanAndEndeGreaterThan(
Long raumId, LocalDateTime ende, LocalDateTime beginn);
}Schritt 3: Der Service setzt die Regeln durch. Zuerst die einfache Plausibilität, dann die Überschneidung, dann das Speichern:
@Service
public class ReservationService {
private final ReservationRepository reservationen;
private final RaumRepository raeume;
private final VereinRepository vereine;
public ReservationService(ReservationRepository reservationen,
RaumRepository raeume, VereinRepository vereine) {
this.reservationen = reservationen;
this.raeume = raeume;
this.vereine = vereine;
}
@Transactional
public Reservation anfragen(Long raumId, Long vereinId,
LocalDateTime beginn, LocalDateTime ende) {
// Regel 1: Zeitraum muss sinnvoll sein
if (!beginn.isBefore(ende)) {
throw new IllegalArgumentException("Beginn muss vor dem Ende liegen");
}
// Regel 2: keine Überschneidung im selben Raum
if (reservationen.existsByRaumIdAndBeginnLessThanAndEndeGreaterThan(raumId, ende, beginn)) {
throw new ReservationKonfliktException("Der Raum ist in diesem Zeitraum bereits reserviert");
}
Raum raum = raeume.findById(raumId)
.orElseThrow(() -> new NichtGefundenException("Raum " + raumId));
Verein verein = vereine.findById(vereinId)
.orElseThrow(() -> new NichtGefundenException("Verein " + vereinId));
// Die Entity startet selbst im Status ANGEFRAGT (Invariante im Konstruktor)
return reservationen.save(new Reservation(raum, verein, beginn, ende));
}
}Schritt 4: Der Controller wird dünn. Er nimmt das DTO an, ruft den Service auf und wandelt das Ergebnis zurück in ein DTO. Die Übersetzung der Exceptions in Statuscodes (409, 400, 404) übernimmt ein zentraler Exception-Handler, den du in Lektion 2 baust.
@RestController
@RequestMapping("/api/reservationen")
public class ReservationController {
private final ReservationService service;
public ReservationController(ReservationService service) {
this.service = service;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ReservationDto anfragen(@RequestBody ReservationAnfrage anfrage) {
Reservation r = service.anfragen(anfrage.raumId(), anfrage.vereinId(),
anfrage.beginn(), anfrage.ende());
return ReservationDto.von(r);
}
}Schritt 5: Die Regel ohne Datenbank testen. Weil der Service sein Repository über den Konstruktor bekommt, setzt der Test mit Mockito einen Ersatz ein:
class ReservationServiceTest {
ReservationRepository reservationen = mock(ReservationRepository.class);
ReservationService service = new ReservationService(
reservationen, mock(RaumRepository.class), mock(VereinRepository.class));
@Test
void anfragen_raumBelegt_wirftKonflikt() {
LocalDateTime beginn = LocalDateTime.of(2026, 11, 3, 19, 0);
LocalDateTime ende = beginn.plusHours(2);
when(reservationen.existsByRaumIdAndBeginnLessThanAndEndeGreaterThan(7L, ende, beginn))
.thenReturn(true);
assertThrows(ReservationKonfliktException.class,
() -> service.anfragen(7L, 12L, beginn, ende));
verify(reservationen, never()).save(any()); // nichts gespeichert
}
}Ergebnis: Die Regel steht an genau einer Stelle, ist in Millisekunden getestet, und jeder künftige Zugang (App, Import, Batch-Job) nutzt automatisch dieselbe Prüfung.
Jetzt bist du dran. Die beiden Labore laufen in JavaScript, die Konzepte sind dieselben wie in Java:
Code-Labor: Überschneidung erkennen. Bevor eine Reservation gespeichert wird, muss die Anwendung wissen, ob sie mit einer bestehenden kollidiert. Schreibe die Funktion ueberschneidetSich(a, b).
Eine Reservation ist ein Objekt wie { raum: "Turnhalle Lindenhof", beginn: "19:00", ende: "21:00" }. Zwei Reservationen überschneiden sich, wenn sie im selben Raum sind und sich die Zeiträume überlappen. Das Ende gehört nicht mehr zum Zeitraum: 19:00 bis 20:30 und 20:30 bis 22:00 sind kein Konflikt.
Die Hilfsfunktion zuMinuten("19:30") liefert 1170 und ist schon fertig.
Code-Labor: Die Regel gehört in den Service. Repository und Controller sind fertig. Ergänze ReservationService.anfragen(raum, verein, beginn, ende):
- Liegt
beginnnicht vorende:ValidierungsFehlerwerfen. - Überschneidet sich der Zeitraum mit einer bestehenden Reservation im selben Raum:
KonfliktFehlerwerfen. - Sonst mit
status: "ANGEFRAGT"speichern und das gespeicherte Objekt zurückgeben.
Der Controller übersetzt deine Fehler in 400 bzw. 409. Beachte: Der Service bekommt sein Repository über den Konstruktor (Dependency Injection).
Typische Fehler
- Regeln im Controller. Sie sind an HTTP gebunden, schwer testbar und werden beim zweiten Zugang kopiert. Verschiebe sie in den Service.
- Dieselbe Regel im Frontend und im Backend, aber unterschiedlich. Das Frontend darf vorprüfen, damit die Person schneller Rückmeldung bekommt. Massgebend ist immer der Server. Wenn die Regel sich ändert, muss sie im Service geändert werden.
- Der Service kennt HTTP. Ein Service, der
ResponseEntityzurückgibt oderHttpServletRequestliest, ist nicht mehr unabhängig. Er wirft fachliche Exceptions, der Controller-Bereich übersetzt sie in Statuscodes. findAll()und dann filtern in Java. Bei zwölf Gemeinden und Tausenden Reservationen lädt das jedes Mal die ganze Tabelle. Lass die Datenbank filtern.- Abhängigkeiten mit
newerzeugen. Dann lässt sich nichts austauschen, und Tests brauchen eine echte Datenbank. Nutze Konstruktor-Injection.
Zusammenfassung
- Eine Multi-User-Applikation braucht Antworten auf vier Fragen: Wer bist du, was darfst du, was passiert bei Gleichzeitigkeit, und wie bleibt der Code beherrschbar.
- Schichten: Controller (HTTP), Service (Regeln), Repository (Datenzugriff), Domain (Fachobjekte). Abhängigkeiten zeigen nur nach unten.
- Geschäftsregeln gehören in den Service, damit sie für jeden Zugang gelten und isoliert testbar sind.
- Dependency Injection über den Konstruktor macht Klassen austauschbar und testbar. Spring Boot und ASP.NET Core bauen die Objekte automatisch zusammen.
- Zwei Zeiträume überschneiden sich, wenn
beginnA < endeBundbeginnB < endeAgilt. - Der Server vertraut dem Browser nie: Jede Prüfung, die zählt, passiert im Backend.
Mit deiner eigenen KI vertiefen
Kopiere einen Prompt in Claude, ChatGPT oder Claude Code. Er macht die KI zur Lernbegleitung statt zum Lösungsautomaten.
Sokratischer Architektur-Coach Chat-KI
Du übst, Code und Regeln der richtigen Schicht zuzuordnen. Die KI stellt Fragen, statt die Lösung zu verraten. Ideal nach Lektion 1 und vor Projekt-Meilenstein 1.