Modulseite

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.

ca. 40 Min.0/5 Checks gelöst

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:

  1. Wer bist du? Ohne Anmeldung weiss die Anwendung nicht, wer gerade klickt.
  2. Was darfst du? Ein Verein darf anfragen, aber nicht genehmigen. Ein Hauswart aus Uzwil darf nichts in Flawil ändern.
  3. Was passiert, wenn zwei gleichzeitig dasselbe tun? Zwei Vereine, eine Halle, derselbe Abend.
  4. 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:

ThemaEinzelplatz-ProgrammMulti-User-Applikation
Datenlokal, in einer Dateizentral in einer Datenbank, alle greifen darauf zu
Benutzereine Personviele Personen mit unterschiedlichen Rollen
Gleichzeitigkeitkommt nicht vorgehört zum Alltag, Konflikte sind normal
Sicherheitwer am Gerät sitzt, darf allesjede Anfrage muss geprüft werden
Fehlerwirkungbetrifft dichbetrifft alle Benutzenden und alle Daten
BetriebProgramm startenServer, 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.

Check 1 · Mehrere AntwortenEinstieg

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:

SchichtAufgabeTypische KlassenWas hier NICHT hingehört
Controller (Präsentation)HTTP-Anfragen annehmen, JSON in DTOs umwandeln, Statuscodes setzenReservationControllerGeschäftsregeln, SQL
Service (Geschäftslogik)Regeln prüfen, Abläufe steuern, TransaktionenReservationServiceHTTP, JSON, Statuscodes
Repository (Datenzugriff)Objekte laden und speichernReservationRepositoryEntscheidungen, Regeln
Domain (Fachmodell)fachliche Objekte mit ihren InvariantenReservation, Raum, VereinDatenbank- 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:

Text
ch.infrakommunal.raumreservation
├── controller   ReservationController, RaumController
├── service      ReservationService, ReservationKonfliktException
├── repository   ReservationRepository, RaumRepository, VereinRepository
├── domain       Reservation, Raum, Verein, Gemeinde, Status
└── dto          ReservationAnfrage, ReservationDto

Die 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.

Check 2 · ZuordnenEinstieg

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.

Java
@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.

Check 3 · Eine AntwortFortgeschritten

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:

Java
@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.

ABbeginnA < endeBbeginnB < endeAÜberschneidung
19:00 bis 21:0020:00 bis 22:00jajaja
19:00 bis 20:3020:30 bis 22:00janein (20:30 < 20:30 ist falsch)nein
18:00 bis 22:0019:00 bis 20:00jajaja
17:00 bis 18:0018:30 bis 19:00janeinnein

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:

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

Java
@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.

Java
@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:

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

Check 4 · Code-LaborEinstieg

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 · JavaScript
Strg + Enter
Check 5 · Code-LaborFortgeschritten

Code-Labor: Die Regel gehört in den Service. Repository und Controller sind fertig. Ergänze ReservationService.anfragen(raum, verein, beginn, ende):

  1. Liegt beginn nicht vor ende: ValidierungsFehler werfen.
  2. Überschneidet sich der Zeitraum mit einer bestehenden Reservation im selben Raum: KonfliktFehler werfen.
  3. 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).

Code-Labor · JavaScript
Strg + Enter

Typische Fehler

  1. Regeln im Controller. Sie sind an HTTP gebunden, schwer testbar und werden beim zweiten Zugang kopiert. Verschiebe sie in den Service.
  2. 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.
  3. Der Service kennt HTTP. Ein Service, der ResponseEntity zurückgibt oder HttpServletRequest liest, ist nicht mehr unabhängig. Er wirft fachliche Exceptions, der Controller-Bereich übersetzt sie in Statuscodes.
  4. 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.
  5. Abhängigkeiten mit new erzeugen. 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 < endeB und beginnB < endeA gilt.
  • 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.