Übungsaufgaben zum Schneiden von Klassen: Single Responsibility, Separation of Concerns, Information Hiding, Tell, don’t ask und Law of Demeter

https://bildung.social/@oerinformatik/

https://oer-informatik.de/klassen-schneiden-uebungsaufgaben

Clean Code, Single Responsibility Principle, Separation of Concerns, Information Hiding, Tell don’t ask, Law of Demeter, Kohäsion, Kopplung, Klasse extrahieren, UML-Klassendiagramm, Übungsaufgaben, Prüfungsvorbereitung

tl/dr; (Bearbeitungszeit insgesamt 86 min, mehrere Aufgaben): Vier Übungsaufgaben zum Schneiden von Klassen: Du zerlegst die Klasse Shop der CSB-Rad-Kata in Klassen mit je einer Verantwortung, löst im Mitfahrlabor Getter-Ketten mit Tell, don’t ask auf, zeichnest für die Fahrtenverwaltung ein Klassendiagramm vor und nach dem Umbau und trennst die Anzeige der Flurmonitore in Speicherung, Fachlogik, Darstellung und Protokollierung [AI-Assisted]. (Zuletzt geändert am 10.11.2026)

Diese Übungsaufgaben gehören zum Artikel Klassen schneiden: Single Responsibility, Separation of Concerns, Information Hiding, Tell, don’t ask und Law of Demeter.

Wortwolke zu den Übungsaufgaben: Single Responsibility Principle, Separation of Concerns, Information Hiding, Tell don’t ask, Law of Demeter, Kohäsion, Kopplung (mit KI erstellt)

Hinweise zur Bearbeitung: Als FIA musst Du zur Teil-2-Prüfung Aufgaben auf Kernkompetenz-Ebene lösen können, das sind Aufgabe 2.1 und Aufgabe 2.2. Die Codeausschnitte stehen in Java und Python; wähle die Sprache, die Du gerade lernst, und schreib Deine Lösung in dieser Sprache. Für die Aufgaben 2.1, 2.2 und 3.2 brauchst Du eine Entwicklungsumgebung mit JUnit 5 bzw. pytest, damit Du nach jedem Umbauschritt die Tests ausführen kannst. Achte auf die Operatoren: „Nenne“ verlangt Stichworte, „Ordne zu“ verlangt die Zuordnung mit kurzer Begründung, „Erläutere“ verlangt eine Erklärung mit Beispiel aus dem Code, „Baue um“ und „Schreibe“ verlangen lauffähigen Code, „Zeichne“ verlangt ein UML-Klassendiagramm, „Beurteile“ und „Nimm Stellung“ verlangen eine begründete eigene Aussage. Schreib Deine Lösung zuerst vollständig auf, bevor Du die Beispiellösung aufklappst.

Du möchtest eigene Aufgaben zum Üben von einem KI-Sprachmodell generieren lassen?

Kopiere den Text in ein KI-Sprachmodell Deiner Wahl und passe Kompetenzstufe, Anzahl, Bearbeitungszeit und Szenario bei Bedarf an. Wie die Aufgaben aufgebaut sind, beschreibt der Artikel Wie sind die Aufgaben aufgebaut?. Prüfe die Ergebnisse mit dem Lehrartikel: KI-Sprachmodelle machen Fehler.

Erstelle neue Übungsaufgaben zum Thema „Schneiden von Klassen: Single
Responsibility, Separation of Concerns, Information Hiding, Tell, don't
ask und Law of Demeter“.

Bauplan: Halte Dich an Aufbau, Kompetenzstufen, Operatoren,
Anforderungsbereiche, Punkte und Zeitschätzung, die im Artikel
https://oer-informatik.de/aufgaben-aufbau beschrieben sind.

Fachliche Grundlage:
- https://oer-informatik.de/klassen-schneiden
- https://oer-informatik.de/methoden-gliedern
- https://oer-informatik.de/refactoring
- https://oer-informatik.de/uml-klassendiagramm
- https://oer-informatik.de/uml-klassendiagramm-assoziation
- https://oer-informatik.de/vererbung-ersetzbarkeit
- Stilbeispiel: https://oer-informatik.de/klassen-schneiden-uebungsaufgaben

Die Aufgaben sollen Inhalte zu diesen Schlagwörtern abdecken:
- Clean Code
- Single Responsibility Principle
- Separation of Concerns
- Information Hiding
- Tell don't ask
- Law of Demeter
- Kohäsion
- Kopplung
- Klasse extrahieren
- UML-Klassendiagramm

Kompetenzstufe: Kernkompetenz (alternativ: Basiskompetenz, Profilkompetenz, Expertise)
Anzahl: 1 Aufgabe mit Teilaufgaben
Bearbeitungszeit in Minuten ca.: <5 bis 30 min>
Szenario: eines der Szenarien aus dem Bauplan

Vorgehen:
1. Lies zuerst die verlinkten Artikel. Kannst Du keine Webseiten
   abrufen, sag mir das, statt zu raten.
2. Steht bei einer Angabe noch ein Platzhalter in <…>, frag mich
   zuerst danach. Richte Punkte und Zahl der Teilaufgaben nach der
   Bearbeitungszeit (ca. 0,9 min je Punkt).
3. Gib je Aufgabe Titel mit Kompetenzstufe, Ausgangssituation und
   Teilaufgaben mit Operator und Punktzahl aus, dazu die geschätzte
   Bearbeitungszeit.
4. Gib die Beispiellösungen mit Punkteverteilung erst aus, wenn ich
   danach frage.

Aufgabe 2.1: Die Klasse Shop nach Verantwortungen zerlegen (Kernkompetenz)

(ca. 30 min Bearbeitungszeit)

Im Onlineshop der CSB-Rad GmbH schließt die Klasse Shop Bestellungen ab. Die Methoden sind schon sauber gegliedert: abschliessen ruft nur noch auf, alle Prüfungen laufen vor der ersten Berechnung (Stand 05 der Code-Kata dieser Artikelreihe). Trotzdem liegen Versandregeln, Rabatt und Mehrwertsteuer, Nummernkreis, Speicherung und Mailtext in derselben Klasse, und Aufrufer bekommen Beträge und gespeicherte Bestellungen als Arrays. Den Code und die 16 Tests findest Du auch im Ordner src/kata/java/stand-05 bzw. src/kata/python/stand-05 des Repositorys zur Reihe.

Die fachlichen Regeln (Beträge in Cent, alle Preise netto, Gewichte in Gramm):

  • Gutscheincode KURIER10: 10 % Rabatt auf den Warenwert, kaufmännisch gerundet; andere Codes werden ignoriert.

  • Paket: 6,90 € je angefangene 31,5 kg, frei ab 100,00 € Warenwert nach Rabatt; Räder können nicht als Paket versandt werden.

  • Spedition: 49,00 € je Rad, ohne Rad einmal 49,00 €. Abholung in der Werkstatt Bielefeld-Sennestadt: kostenlos.

  • Mehrwertsteuer 19 % auf Warenwert − Rabatt + Versand; abgelehnt werden Bestellungen ohne Positionen, mit Menge ≤ 0, mit Warenwert 0, mit unbekannter Versandart und Räder im Paket.

// Position.java
package de.csbrad.shop;

/** Eine Bestellposition; Preise sind Nettopreise in Cent, Gewichte in Gramm. */
public record Position(String artikelnr, String bezeichnung, int nettopreisCent, int menge, int gewichtGramm, boolean istRad) {
}

// Bestellanfrage.java
package de.csbrad.shop;

import java.util.List;

/** Parameterobjekt: alles, was der Bestellabschluss als Eingabe braucht. */
public record Bestellanfrage(List<Position> positionen, String versandart, String gutscheincode, String kundenEmail) {
}

// Shop.java
package de.csbrad.shop;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

public class Shop {

    private static final int ERSTE_BESTELLNUMMER = 1001;
    private static final String GUTSCHEINCODE_KURIER = "KURIER10";
    private static final int GUTSCHEIN_RABATT_PROZENT = 10;
    private static final int PAKETPAUSCHALE_CENT = 690;
    private static final int HOECHSTGEWICHT_PAKET_GRAMM = 31500;
    private static final int VERSANDKOSTENFREI_AB_CENT = 10000;
    private static final int SPEDITION_JE_RAD_CENT = 4900;
    private static final int MEHRWERTSTEUER_PROZENT = 19;
    private static final Set<String> VERSANDARTEN = Set.of("PAKET", "SPEDITION", "ABHOLUNG");
    private static final int GESAMTBETRAG = 4;

    private int naechsteBestellnummer = ERSTE_BESTELLNUMMER;
    private final List<Object[]> bestellungen = new ArrayList<>();
    private final List<String> postausgang = new ArrayList<>();

    public List<Object[]> getBestellungen() {
        return bestellungen;
    }

    public List<String> getPostausgang() {
        return postausgang;
    }

    // ---- Integration: ruft nur auf, rechnet nicht selbst ----

    /**
     * Schließt eine Bestellung ab: prüfen, berechnen, Nummer vergeben, bestätigen, speichern.
     *
     * @return {Warenwert, Rabatt, Versandkosten, Mehrwertsteuer, Gesamtbetrag} in Cent
     */
    public int[] abschliessen(Bestellanfrage anfrage) {
        pruefe(anfrage);
        int[] betraege = berechneBetraege(anfrage);
        int bestellnummer = vergibBestellnummer();
        versendeBestaetigung(anfrage, bestellnummer, betraege[GESAMTBETRAG]);
        speichere(anfrage, bestellnummer, betraege[GESAMTBETRAG]);
        return betraege;
    }

    private void pruefe(Bestellanfrage anfrage) {
        pruefeMengen(anfrage.positionen());
        pruefeWarenwert(berechneWarenwert(anfrage.positionen()));
        pruefeVersandart(anfrage.versandart());
        pruefeVersandmoeglich(anfrage.positionen(), anfrage.versandart());
    }

    private int[] berechneBetraege(Bestellanfrage anfrage) {
        int warenwert = berechneWarenwert(anfrage.positionen());
        int rabatt = berechneRabatt(warenwert, anfrage.gutscheincode());
        int versandkosten = berechneVersandkosten(anfrage.positionen(), anfrage.versandart(), warenwert - rabatt);
        int nettobetrag = warenwert - rabatt + versandkosten;
        int mehrwertsteuer = berechneMehrwertsteuer(nettobetrag);
        return new int[] {warenwert, rabatt, versandkosten, mehrwertsteuer, nettobetrag + mehrwertsteuer};
    }

    private int berechneVersandkosten(List<Position> positionen, String versandart, int warenwertNachRabatt) {
        return switch (versandart) {
            case "PAKET" -> berechnePaketkosten(berechneAnzahlPakete(positionen), warenwertNachRabatt);
            case "SPEDITION" -> berechneSpeditionskosten(zaehleRaeder(positionen));
            default -> 0;
        };
    }

    // ---- Operationen: rechnen oder prüfen, rufen keine eigenen Methoden auf ----

    private static void pruefeMengen(List<Position> positionen) {
        if (positionen.isEmpty()) {
            throw new IllegalArgumentException("Bestellung ohne Positionen");
        }
        for (Position position : positionen) {
            if (position.menge() <= 0) {
                throw new IllegalArgumentException("Menge muss positiv sein: " + position.artikelnr());
            }
        }
    }

    private static void pruefeWarenwert(int warenwert) {
        if (warenwert == 0) {
            throw new IllegalArgumentException("Bestellung ohne Warenwert");
        }
    }

    private static void pruefeVersandart(String versandart) {
        if (!VERSANDARTEN.contains(versandart)) {
            throw new IllegalArgumentException("Unbekannte Versandart: " + versandart);
        }
    }

    private static void pruefeVersandmoeglich(List<Position> positionen, String versandart) {
        boolean enthaeltRad = positionen.stream().anyMatch(Position::istRad);
        if (versandart.equals("PAKET") && enthaeltRad) {
            throw new IllegalArgumentException("Räder können nicht als Paket versandt werden");
        }
    }

    private static int berechneWarenwert(List<Position> positionen) {
        int warenwert = 0;
        for (Position position : positionen) {
            warenwert = warenwert + position.nettopreisCent() * position.menge();
        }
        return warenwert;
    }

    private static int berechneRabatt(int warenwert, String gutscheincode) {
        if (GUTSCHEINCODE_KURIER.equals(gutscheincode)) {
            return (warenwert * GUTSCHEIN_RABATT_PROZENT + 50) / 100;
        }
        return 0;
    }

    private static int berechneAnzahlPakete(List<Position> positionen) {
        int gesamtgewicht = 0;
        for (Position position : positionen) {
            gesamtgewicht = gesamtgewicht + position.gewichtGramm() * position.menge();
        }
        return Math.max(1, (gesamtgewicht + HOECHSTGEWICHT_PAKET_GRAMM - 1) / HOECHSTGEWICHT_PAKET_GRAMM);
    }

    private static int berechnePaketkosten(int anzahlPakete, int warenwertNachRabatt) {
        if (warenwertNachRabatt >= VERSANDKOSTENFREI_AB_CENT) {
            return 0;
        }
        return anzahlPakete * PAKETPAUSCHALE_CENT;
    }

    private static int zaehleRaeder(List<Position> positionen) {
        int anzahlRaeder = 0;
        for (Position position : positionen) {
            if (position.istRad()) {
                anzahlRaeder = anzahlRaeder + position.menge();
            }
        }
        return anzahlRaeder;
    }

    private static int berechneSpeditionskosten(int anzahlRaeder) {
        // ohne Rad wird eine Speditionspauschale berechnet
        return Math.max(1, anzahlRaeder) * SPEDITION_JE_RAD_CENT;
    }

    private static int berechneMehrwertsteuer(int nettobetrag) {
        return (nettobetrag * MEHRWERTSTEUER_PROZENT + 50) / 100;
    }

    private int vergibBestellnummer() {
        int bestellnummer = naechsteBestellnummer;
        naechsteBestellnummer = naechsteBestellnummer + 1;
        return bestellnummer;
    }

    private void versendeBestaetigung(Bestellanfrage anfrage, int bestellnummer, int gesamtbetrag) {
        String betrag = (gesamtbetrag / 100) + "," + String.format("%02d", gesamtbetrag % 100) + " EUR";
        String versandzeile = switch (anfrage.versandart()) {
            case "PAKET" -> "Versand: Paket";
            case "SPEDITION" -> "Versand: Spedition";
            default -> "Abholung in der Werkstatt Bielefeld-Sennestadt";
        };
        postausgang.add("An: " + anfrage.kundenEmail() + "\nBestellung CSB-" + bestellnummer + "\nGesamtbetrag: " + betrag + "\n" + versandzeile);
    }

    private void speichere(Bestellanfrage anfrage, int bestellnummer, int gesamtbetrag) {
        bestellungen.add(new Object[] {"CSB-" + bestellnummer, anfrage.kundenEmail(), anfrage.positionen(), gesamtbetrag});
    }
}

a) Nenne die Verantwortungen der Klasse Shop. Gib zu jeder an, wer bei der CSB-Rad GmbH eine Änderung verlangen würde, und ordne ihr die zugehörigen Methoden zu. (4 Punkte)

Verantwortungen der Klasse Shop (Beispiellösung)
Verantwortung Akteur Methoden (Java-Namen)
Beträge einer Bestellung Vertrieb, Buchhaltung berechneWarenwert, berechneRabatt, berechneMehrwertsteuer, berechneBetraege, pruefeMengen, pruefeWarenwert
Versandregeln Logistik berechneVersandkosten, berechneAnzahlPakete, berechnePaketkosten, zaehleRaeder, berechneSpeditionskosten, pruefeVersandart, pruefeVersandmoeglich
Bestätigung an die Kundschaft Kundenservice versendeBestaetigung, getPostausgang
Nummernkreis und Speicherung IT-Betrieb, Buchhaltung vergibBestellnummer, speichere, getBestellungen
Ablauf des Abschlusses Shop-Verantwortliche abschliessen, pruefe

Bewertung: je Verantwortung mit passendem Akteur und Methoden 1 Punkt, höchstens 4 Punkte. Andere sinnvolle Schnitte (zum Beispiel Prüfregeln als eigene Verantwortung) sind gleichwertig, wenn die Akteure begründet sind.

b) Baue die Klasse Shop mit den Refactorings „Klasse extrahieren“ und „Methode verschieben“ in kleinen Schritten um. Ziel sind die Klassen Bestellung (übernimmt die Werte der Anfrage, prüft ihre Positionen im Konstruktor und berechnet warenwert, rabatt, warenwertNachRabatt, versandkosten, mehrwertsteuer, gesamtbetrag, gesamtgewichtGramm, anzahlRaeder und enthaeltRad selbst), Versandkostenrechner (Versandregeln und Versandprüfungen), Mailversand (Postausgang und Mailtext), BestellRepository (vergibt die Nummer beim Speichern, findeNachNummer, alle) und Bestellabschluss (Ablauf; abschliessen gibt die Bestellung zurück). Passe die Tests nur so weit an, wie sich die öffentliche Schnittstelle ändert; die erwarteten Beträge und Mailtexte bleiben gleich. (12 Punkte)

Position und Bestellanfrage bleiben unverändert. Die angepassten Tests stehen im Ordner src/kata/java/stand-06 bzw. src/kata/python/stand-06; sie lesen die fünf Beträge mit einer Hilfsmethode betraege(bestellung) aus und greifen über abschluss.mailversand() und abschluss.repository() auf Postausgang und gespeicherte Bestellungen zu.

// Bestellung.java
package de.csbrad.shop;

import java.util.List;

/** Eine Bestellung kennt ihre Positionen und berechnet ihre Beträge selbst (Tell, don't ask). */
public final class Bestellung {

    private static final String GUTSCHEINCODE_KURIER = "KURIER10";
    private static final int GUTSCHEIN_RABATT_PROZENT = 10;
    private static final int MEHRWERTSTEUER_PROZENT = 19;

    private final List<Position> positionen;
    private final String versandart;
    private final String gutscheincode;
    private final String kundenEmail;
    private final Versandkostenrechner versandkostenrechner;
    private String bestellnummer;

    public Bestellung(Bestellanfrage anfrage, Versandkostenrechner versandkostenrechner) {
        pruefePositionen(anfrage.positionen());
        this.positionen = List.copyOf(anfrage.positionen());
        this.versandart = anfrage.versandart();
        this.gutscheincode = anfrage.gutscheincode();
        this.kundenEmail = anfrage.kundenEmail();
        this.versandkostenrechner = versandkostenrechner;
        if (warenwert() == 0) {
            throw new IllegalArgumentException("Bestellung ohne Warenwert");
        }
    }

    private static void pruefePositionen(List<Position> positionen) {
        if (positionen.isEmpty()) {
            throw new IllegalArgumentException("Bestellung ohne Positionen");
        }
        for (Position position : positionen) {
            if (position.menge() <= 0) {
                throw new IllegalArgumentException("Menge muss positiv sein: " + position.artikelnr());
            }
        }
    }

    public int warenwert() {
        return positionen.stream().mapToInt(p -> p.nettopreisCent() * p.menge()).sum();
    }

    public int rabatt() {
        if (GUTSCHEINCODE_KURIER.equals(gutscheincode)) {
            return prozentKaufmaennischGerundet(warenwert(), GUTSCHEIN_RABATT_PROZENT);
        }
        return 0;
    }

    public int warenwertNachRabatt() {
        return warenwert() - rabatt();
    }

    public int versandkosten() {
        return versandkostenrechner.berechne(this);
    }

    public int mehrwertsteuer() {
        return prozentKaufmaennischGerundet(warenwertNachRabatt() + versandkosten(), MEHRWERTSTEUER_PROZENT);
    }

    public int gesamtbetrag() {
        return warenwertNachRabatt() + versandkosten() + mehrwertsteuer();
    }

    public int gesamtgewichtGramm() {
        return positionen.stream().mapToInt(p -> p.gewichtGramm() * p.menge()).sum();
    }

    public int anzahlRaeder() {
        return positionen.stream().filter(Position::istRad).mapToInt(Position::menge).sum();
    }

    public boolean enthaeltRad() {
        return positionen.stream().anyMatch(Position::istRad);
    }

    public String versandart() {
        return versandart;
    }

    public String kundenEmail() {
        return kundenEmail;
    }

    public String bestellnummer() {
        return bestellnummer;
    }

    void vergibNummer(String nummer) {
        if (bestellnummer != null) {
            throw new IllegalStateException("Bestellung hat schon eine Nummer: " + bestellnummer);
        }
        bestellnummer = nummer;
    }

    private static int prozentKaufmaennischGerundet(int betragCent, int prozent) {
        return (betragCent * prozent + 50) / 100;
    }
}

// Versandkostenrechner.java
package de.csbrad.shop;

import java.util.Set;

/** Kennt die Versandregeln der CSB-Rad GmbH. */
public class Versandkostenrechner {

    private static final Set<String> VERSANDARTEN = Set.of("PAKET", "SPEDITION", "ABHOLUNG");
    private static final int PAKETPAUSCHALE_CENT = 690;
    private static final int HOECHSTGEWICHT_PAKET_GRAMM = 31500;
    private static final int VERSANDKOSTENFREI_AB_CENT = 10000;
    private static final int SPEDITION_JE_RAD_CENT = 4900;

    public void pruefeVersandmoeglich(Bestellung bestellung) {
        if (!VERSANDARTEN.contains(bestellung.versandart())) {
            throw new IllegalArgumentException("Unbekannte Versandart: " + bestellung.versandart());
        }
        if (bestellung.versandart().equals("PAKET") && bestellung.enthaeltRad()) {
            throw new IllegalArgumentException("Räder können nicht als Paket versandt werden");
        }
    }

    public int berechne(Bestellung bestellung) {
        return switch (bestellung.versandart()) {
            case "PAKET" -> paketkosten(bestellung);
            // ohne Rad wird eine Speditionspauschale berechnet
            case "SPEDITION" -> Math.max(1, bestellung.anzahlRaeder()) * SPEDITION_JE_RAD_CENT;
            default -> 0;
        };
    }

    private int paketkosten(Bestellung bestellung) {
        if (bestellung.warenwertNachRabatt() >= VERSANDKOSTENFREI_AB_CENT) {
            return 0;
        }
        int anzahlPakete = Math.max(1, (bestellung.gesamtgewichtGramm() + HOECHSTGEWICHT_PAKET_GRAMM - 1) / HOECHSTGEWICHT_PAKET_GRAMM);
        return anzahlPakete * PAKETPAUSCHALE_CENT;
    }
}

// Mailversand.java
package de.csbrad.shop;

import java.util.ArrayList;
import java.util.List;

/** Verschickt Bestätigungsmails; hier nur in einen Postausgang (Liste). */
public class Mailversand {

    private final List<String> postausgang = new ArrayList<>();

    public void sendeBestaetigung(Bestellung bestellung) {
        postausgang.add("An: " + bestellung.kundenEmail() + "\nBestellung " + bestellung.bestellnummer()
                + "\nGesamtbetrag: " + alsEuroText(bestellung.gesamtbetrag()) + "\n" + versandzeile(bestellung.versandart()));
    }

    public List<String> postausgang() {
        return List.copyOf(postausgang);
    }

    private static String versandzeile(String versandart) {
        return switch (versandart) {
            case "PAKET" -> "Versand: Paket";
            case "SPEDITION" -> "Versand: Spedition";
            default -> "Abholung in der Werkstatt Bielefeld-Sennestadt";
        };
    }

    private static String alsEuroText(int betragCent) {
        return (betragCent / 100) + "," + String.format("%02d", betragCent % 100) + " EUR";
    }
}

// BestellRepository.java
package de.csbrad.shop;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/** Speichert Bestellungen (hier im Arbeitsspeicher) und vergibt fortlaufende Bestellnummern. */
public class BestellRepository {

    private static final int ERSTE_BESTELLNUMMER = 1001;

    private final List<Bestellung> bestellungen = new ArrayList<>();
    private int naechsteNummer = ERSTE_BESTELLNUMMER;

    public void speichere(Bestellung bestellung) {
        bestellung.vergibNummer("CSB-" + naechsteNummer);
        naechsteNummer = naechsteNummer + 1;
        bestellungen.add(bestellung);
    }

    public Optional<Bestellung> findeNachNummer(String bestellnummer) {
        return bestellungen.stream().filter(b -> b.bestellnummer().equals(bestellnummer)).findFirst();
    }

    public List<Bestellung> alle() {
        return List.copyOf(bestellungen);
    }
}

// Bestellabschluss.java
package de.csbrad.shop;

/** Ablauf des Bestellabschlusses: prüfen, speichern, bestätigen. */
public class Bestellabschluss {

    private final Versandkostenrechner versandkostenrechner = new Versandkostenrechner();
    private final Mailversand mailversand = new Mailversand();
    private final BestellRepository repository = new BestellRepository();

    public Bestellung abschliessen(Bestellanfrage anfrage) {
        Bestellung bestellung = new Bestellung(anfrage, versandkostenrechner);
        versandkostenrechner.pruefeVersandmoeglich(bestellung);
        repository.speichere(bestellung);
        mailversand.sendeBestaetigung(bestellung);
        return bestellung;
    }

    public Mailversand mailversand() {
        return mailversand;
    }

    public BestellRepository repository() {
        return repository;
    }
}

Bewertung: Bestellung rechnet alle Beträge aus den eigenen Positionen und prüft im Konstruktor (3 Punkte), Versandkostenrechner mit Regeln und Prüfungen (2 Punkte), Mailversand und BestellRepository mit Nummernvergabe beim Speichern und Kopie in alle() (3 Punkte), Bestellabschluss steuert nur den Ablauf (2 Punkte), Tests grün und nur an der Schnittstelle angepasst (2 Punkte).

c) Erläutere an je einer Stelle Deiner Lösung, wo sie Tell, don’t ask und wo sie Information Hiding umsetzt, und nenne eine Stelle, an der ein Objekt immer noch nach Daten gefragt wird, um danach selbst zu entscheiden. (3 Punkte)

  • Tell, don’t ask: Der Versandkostenrechner fragt die Bestellung nach warenwertNachRabatt() und gesamtgewichtGramm(), statt über die Positionen zu laufen und Warenwert und Rabatt selbst zu berechnen. Die Entscheidung über Rabatt und Gewicht liegt bei der Bestellung, die die Positionen hat (1 Punkt).

  • Information Hiding: BestellRepository verbirgt, wie Nummern vergeben und Bestellungen gespeichert werden; alle() gibt eine Kopie heraus. Vorher musste jeder Aufrufer wissen, dass Index 3 eines Object[] der Gesamtbetrag ist (1 Punkt).

  • noch „Ask“: Versandkostenrechner.berechne fragt bestellung.versandart() und unterscheidet mit switch bzw. match; ebenso Mailversand bei der Versandzeile (1 Punkt).

d) In den angepassten Tests steht abschluss.mailversand().postausgang().get(0). Nimm Stellung, ob das gegen das Law of Demeter verstößt und was die eigentliche Ursache ist. (3 Punkte)

Ja: Der Test redet mit dem Bestellabschluss (Freund), greift aber über dessen Rückgabewert auf den Mailversand und dessen Liste zu, also auf Fremde (1 Punkt). get(0) an einer Liste allein wäre unproblematisch, die Liste ist eine Datenstruktur. Die Ursache ist, dass Bestellabschluss seinen Mailversand selbst erzeugt; der Test kommt nur über einen Getter heran, der allein für den Test existiert (1 Punkt). Besser wäre es, den Mailversand beim Erzeugen des Bestellabschluss zu übergeben; dann hält der Test ihn selbst in der Hand und braucht den Getter nicht. Ein Hide Delegate wie abschluss.ersteMail() würde nur die Kette verstecken und dem Bestellabschluss eine fremde Aufgabe geben (1 Punkt).

Aufgabe 2.2: Getter-Ketten im Fahrtangebot auflösen (Kernkompetenz)

(ca. 18 min Bearbeitungszeit)

In der Mitfahr-App Mitfahrlabor bestätigt der Buchungsdienst Mitfahrten, berechnet den Kostenbeitrag und zeigt Fahrenden in der Probezeit einen Hinweis zu ihrer Verantwortung. Die Regeln: Die Probezeit dauert zwei Jahre ab Erteilung des Führerscheins, der Kostenbeitrag beträgt höchstens 0,10 € je km und Person, und eine Mitfahrt kann nur bestätigt werden, solange ein Platz frei ist. Der Code funktioniert, aber der Buchungsdienst greift tief in die anderen Klassen hinein.

// Person.java
package de.mitfahrlabor;

import java.time.LocalDate;

/** Reine Datenstruktur: Name, Alter, Führerschein seit (null = kein Führerschein). */
public record Person(String name, int alter, LocalDate fuehrerscheinSeit) {
}

// Fahrer.java
package de.mitfahrlabor;

public class Fahrer {

    private final Person person;

    public Fahrer(Person person) {
        this.person = person;
    }

    public Person getPerson() {
        return person;
    }
}

// Mitfahrt.java
package de.mitfahrlabor;

public class Mitfahrt {

    private final Person mitfahrende;
    private boolean bestaetigt;

    public Mitfahrt(Person mitfahrende) {
        this.mitfahrende = mitfahrende;
    }

    public Person getMitfahrende() {
        return mitfahrende;
    }

    public boolean isBestaetigt() {
        return bestaetigt;
    }

    public void setBestaetigt(boolean bestaetigt) {
        this.bestaetigt = bestaetigt;
    }
}

// Fahrtangebot.java
package de.mitfahrlabor;

import java.util.ArrayList;
import java.util.List;

public class Fahrtangebot {

    private final Fahrer fahrer;
    private final String haltepunkt;
    private final int plaetze;
    private final int entfernungKm;
    private final int centJeKm;
    private final List<Mitfahrt> mitfahrten = new ArrayList<>();

    public Fahrtangebot(Fahrer fahrer, String haltepunkt, int plaetze, int entfernungKm, int centJeKm) {
        this.fahrer = fahrer;
        this.haltepunkt = haltepunkt;
        this.plaetze = plaetze;
        this.entfernungKm = entfernungKm;
        this.centJeKm = centJeKm;
    }

    public Fahrer getFahrer() {
        return fahrer;
    }

    public String getHaltepunkt() {
        return haltepunkt;
    }

    public int getPlaetze() {
        return plaetze;
    }

    public int getEntfernungKm() {
        return entfernungKm;
    }

    public int getCentJeKm() {
        return centJeKm;
    }

    public List<Mitfahrt> getMitfahrten() {
        return mitfahrten;
    }
}

// Buchungsdienst.java
package de.mitfahrlabor;

import java.time.LocalDate;

public class Buchungsdienst {

    public String probezeitHinweis(Fahrtangebot fahrt, LocalDate heute) {
        if (fahrt.getFahrer().getPerson().fuehrerscheinSeit().plusYears(2).isAfter(heute)) {
            return "Hinweis für " + fahrt.getFahrer().getPerson().name() + ": Du bist in der Probezeit und trägst Verantwortung für Deine Mitfahrenden.";
        }
        return "";
    }

    public void bestaetige(Fahrtangebot fahrt, Mitfahrt mitfahrt) {
        int belegt = 0;
        for (Mitfahrt m : fahrt.getMitfahrten()) {
            if (m.isBestaetigt()) {
                belegt = belegt + 1;
            }
        }
        if (belegt >= fahrt.getPlaetze()) {
            throw new IllegalStateException("Kein Platz mehr frei");
        }
        mitfahrt.setBestaetigt(true);
        if (!fahrt.getMitfahrten().contains(mitfahrt)) {
            fahrt.getMitfahrten().add(mitfahrt);
        }
    }

    public int kostenbeitragCent(Fahrtangebot fahrt) {
        return Math.min(fahrt.getCentJeKm(), 10) * fahrt.getEntfernungKm();
    }
}

a) Nenne die Getter-Ketten und die Stellen, an denen der Buchungsdienst Daten abfragt, um selbst über ein anderes Objekt zu entscheiden. Ordne jede Stelle dem Law of Demeter oder Tell, don’t ask zu. (4 Punkte)

  • fahrt.getFahrer().getPerson().fuehrerscheinSeit() in probezeitHinweis: Getter-Kette über zwei Fremde, Law of Demeter; zugleich entscheidet der Dienst selbst über die Probezeit (Tell, don’t ask) (1 Punkt).

  • fahrt.getFahrer().getPerson().name(): Getter-Kette, Law of Demeter (1 Punkt).

  • Schleife über fahrt.getMitfahrten() mit isBestaetigt() und Vergleich mit getPlaetze() in bestaetige, danach setBestaetigt(true) und getMitfahrten().add(…): Der Dienst entscheidet über freie Plätze und ändert die interne Liste der Fahrt, Tell, don’t ask (und fehlendes Information Hiding, weil die Liste veränderbar herausgegeben wird) (1 Punkt).

  • Math.min(fahrt.getCentJeKm(), 10) * fahrt.getEntfernungKm() in kostenbeitragCent: Die Regel zum Höchstbeitrag steht außerhalb der Fahrt, Tell, don’t ask (1 Punkt).

b) Baue die Klassen so um, dass der Buchungsdienst nur noch mit dem Fahrtangebot redet: Fahrer beantwortet, ob er in der Probezeit ist, Fahrtangebot kennt seine freien Plätze, bestätigt Mitfahrten selbst und berechnet den Kostenbeitrag. Die Liste der Mitfahrten und der Setter für bestaetigt sollen nicht mehr öffentlich sein. Der Hinweistext und das Verhalten bleiben gleich. (8 Punkte)

Person bleibt unverändert.

// Fahrer.java
package de.mitfahrlabor;

import java.time.LocalDate;

public class Fahrer {

    private static final int PROBEZEIT_JAHRE = 2;

    private final Person person;

    public Fahrer(Person person) {
        this.person = person;
    }

    public String name() {
        return person.name();
    }

    public boolean inProbezeit(LocalDate heute) {
        return person.fuehrerscheinSeit().plusYears(PROBEZEIT_JAHRE).isAfter(heute);
    }
}

// Mitfahrt.java
package de.mitfahrlabor;

public class Mitfahrt {

    private final Person mitfahrende;
    private boolean bestaetigt;

    public Mitfahrt(Person mitfahrende) {
        this.mitfahrende = mitfahrende;
    }

    public String name() {
        return mitfahrende.name();
    }

    public boolean istBestaetigt() {
        return bestaetigt;
    }

    void bestaetige() {
        bestaetigt = true;
    }
}

// Fahrtangebot.java
package de.mitfahrlabor;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class Fahrtangebot {

    private static final int HOECHSTBEITRAG_CENT_JE_KM = 10;

    private final Fahrer fahrer;
    private final String haltepunkt;
    private final int plaetze;
    private final int entfernungKm;
    private final int centJeKm;
    private final List<Mitfahrt> mitfahrten = new ArrayList<>();

    public Fahrtangebot(Fahrer fahrer, String haltepunkt, int plaetze, int entfernungKm, int centJeKm) {
        this.fahrer = fahrer;
        this.haltepunkt = haltepunkt;
        this.plaetze = plaetze;
        this.entfernungKm = entfernungKm;
        this.centJeKm = centJeKm;
    }

    public String haltepunkt() {
        return haltepunkt;
    }

    public String fahrerName() {
        return fahrer.name();
    }

    public boolean fahrerInProbezeit(LocalDate heute) {
        return fahrer.inProbezeit(heute);
    }

    public int freiePlaetze() {
        return plaetze - (int) mitfahrten.stream().filter(Mitfahrt::istBestaetigt).count();
    }

    public void bestaetige(Mitfahrt mitfahrt) {
        if (freiePlaetze() <= 0) {
            throw new IllegalStateException("Kein Platz mehr frei");
        }
        mitfahrt.bestaetige();
        if (!mitfahrten.contains(mitfahrt)) {
            mitfahrten.add(mitfahrt);
        }
    }

    public int kostenbeitragCent() {
        return Math.min(centJeKm, HOECHSTBEITRAG_CENT_JE_KM) * entfernungKm;
    }
}

// Buchungsdienst.java
package de.mitfahrlabor;

import java.time.LocalDate;

public class Buchungsdienst {

    public String probezeitHinweis(Fahrtangebot fahrt, LocalDate heute) {
        if (fahrt.fahrerInProbezeit(heute)) {
            return "Hinweis für " + fahrt.fahrerName() + ": Du bist in der Probezeit und trägst Verantwortung für Deine Mitfahrenden.";
        }
        return "";
    }

    public void bestaetige(Fahrtangebot fahrt, Mitfahrt mitfahrt) {
        fahrt.bestaetige(mitfahrt);
    }

    public int kostenbeitragCent(Fahrtangebot fahrt) {
        return fahrt.kostenbeitragCent();
    }
}

In Python gibt es keine paketprivate Sichtbarkeit; _bestaetige ist per Konvention intern und wird nur von Fahrtangebot im selben Modul aufgerufen.

Bewertung: Fahrer.inProbezeit und Fahrtangebot.fahrerInProbezeit/fahrerName statt Kette (2 Punkte), freiePlaetze und bestaetige im Fahrtangebot, Liste nicht mehr herausgegeben (3 Punkte), Setter durch eine nicht öffentliche Methode ersetzt (1 Punkt), kostenbeitragCent mit benannter Konstante im Fahrtangebot (1 Punkt), Buchungsdienst ohne Ketten und ohne eigene Entscheidungen über die Fahrt (1 Punkt).

c) Schreibe Tests mit JUnit 5 bzw. pytest: Ben Richter (Führerschein seit 15.01.2026) bietet am 09.11.2026 eine Fahrt ab Gütersloh ZOB mit 18 km zu 8 Cent je km an und bekommt den Hinweis; Niklas Fischer (Führerschein seit 04.05.2020) bekommt keinen. Eine Fahrt mit einem Platz, die schon Aylin Kaya mitnimmt, lehnt Oksana Melnyk ab, ohne dass Oksanas Mitfahrt als bestätigt gilt. Prüfe außerdem den Kostenbeitrag von Bens Fahrt. (4 Punkte)

package de.mitfahrlabor;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.time.LocalDate;
import org.junit.jupiter.api.Test;

class FahrtangebotTest {

    static final LocalDate HEUTE = LocalDate.of(2026, 11, 9);
    static final Person BEN = new Person("Ben Richter", 18, LocalDate.of(2026, 1, 15));
    static final Person NIKLAS = new Person("Niklas Fischer", 24, LocalDate.of(2020, 5, 4));
    static final Person AYLIN = new Person("Aylin Kaya", 17, null);
    static final Person OKSANA = new Person("Oksana Melnyk", 23, null);

    @Test
    void benIstInDerProbezeit() {
        Fahrtangebot fahrt = new Fahrtangebot(new Fahrer(BEN), "Gütersloh ZOB", 3, 18, 8);
        assertTrue(fahrt.fahrerInProbezeit(HEUTE));
        assertTrue(new Buchungsdienst().probezeitHinweis(fahrt, HEUTE).startsWith("Hinweis für Ben Richter"));
    }

    @Test
    void niklasIstNichtInDerProbezeit() {
        Fahrtangebot fahrt = new Fahrtangebot(new Fahrer(NIKLAS), "Bad Salzuflen Bahnhof", 7, 15, 10);
        assertFalse(fahrt.fahrerInProbezeit(HEUTE));
        assertEquals("", new Buchungsdienst().probezeitHinweis(fahrt, HEUTE));
    }

    @Test
    void volleFahrtLehntAb() {
        Fahrtangebot fahrt = new Fahrtangebot(new Fahrer(BEN), "Gütersloh ZOB", 1, 18, 8);
        fahrt.bestaetige(new Mitfahrt(AYLIN));
        Mitfahrt oksana = new Mitfahrt(OKSANA);
        assertThrows(IllegalStateException.class, () -> fahrt.bestaetige(oksana));
        assertFalse(oksana.istBestaetigt());
        assertEquals(0, fahrt.freiePlaetze());
    }

    @Test
    void kostenbeitragHoechstensZehnCentJeKm() {
        assertEquals(144, new Fahrtangebot(new Fahrer(BEN), "Gütersloh ZOB", 3, 18, 8).kostenbeitragCent());
        assertEquals(150, new Fahrtangebot(new Fahrer(NIKLAS), "Bad Salzuflen Bahnhof", 7, 15, 12).kostenbeitragCent());
    }
}

Bens Kostenbeitrag: 18 km · 8 ct = 144 ct; der zweite Fall im letzten Test zeigt, dass 12 ct je km auf 10 ct begrenzt werden (15 km · 10 ct = 150 ct). Bewertung: je Testfall 1 Punkt; der Ablehnungstest muss die Ausnahme und den unveränderten Zustand prüfen.

d) Oksana Melnyk aus dem Projektteam fragt: „person.name() ist doch auch ein Getter. Und der Buchungsdienst reicht jetzt fast alles nur noch weiter – ist das nicht genauso schlecht?“ Nimm zu beiden Punkten Stellung. (3 Punkte)

  • person.name(): Person ist ein Record bzw. eine Dataclass ohne eigenes Verhalten, also eine Datenstruktur. Ihre Felder sind ihre Schnittstelle; der Zugriff darauf verstößt nicht gegen das Law of Demeter. Problematisch war, dass der Buchungsdienst sich über zwei Objekte hinweg bis zur Person durchgehangelt hat (1,5 Punkte).

  • Weiterreichen: bestaetige und kostenbeitragCent im Buchungsdienst sind tatsächlich nur noch Weiterreicher (Middle Man). Wenn der Dienst keine eigene Aufgabe mehr hat (etwa Benachrichtigungen auslösen oder Rechte prüfen), können Aufrufer direkt mit dem Fahrtangebot reden (Remove Middle Man). probezeitHinweis hat dagegen eine eigene Aufgabe, den Text für die Anzeige (1,5 Punkte).

Aufgabe 3.1: Die Fahrtenverwaltung neu schneiden (Profilkompetenz)

(ca. 20 min Bearbeitungszeit)

Im Mitfahrlabor ist die Klasse Fahrtenverwaltung mit jeder neuen Anforderung gewachsen. Das Klassendiagramm zeigt den Stand vor dem Umbau:

UML-Klassendiagramm vor dem Umbau: Klasse Fahrtenverwaltung mit den Attributen fahrten, postausgang und protokoll und den Methoden bieteAn, frageAn, bestaetige, kostenbeitragCent, monitorzeilen, sendeErinnerungen, speichereAlsCsv und protokolliere; sie enthält beliebig viele Fahrten; Fahrt hat nur öffentliche Attribute für Haltepunkt, Abfahrt, Plätze, Entfernung, Cent je km, Anfragende und Bestätigte und kennt genau eine Person als Fahrer; Person ist ein Datentyp mit Name, Alter, Einwilligung und Führerschein seit (mit KI erstellt)

Die Methoden tun Folgendes:

Methoden der Klasse Fahrtenverwaltung
Methode Aufgabe
bieteAn prüft, dass der Kostenbeitrag höchstens 10 ct je km beträgt, legt die Fahrt an und schreibt einen Protokolleintrag
frageAn prüft bei Minderjährigen, ob die Einwilligung vorliegt, trägt die Person bei anfragende ein und schreibt der fahrenden Person eine Mail
bestaetige zählt die Einträge in bestaetigte, vergleicht mit plaetze, trägt ein, schreibt der Person eine Mail und, wenn der Führerschein jünger als zwei Jahre ist, der fahrenden Person einen Probezeit-Hinweis
kostenbeitragCent multipliziert centJeKm mit entfernungKm
monitorzeilen erzeugt für jede Fahrt mit freien Plätzen eine Zeile für die Flurmonitore, ohne Namen
sendeErinnerungen schreibt allen Bestätigten einer Fahrt, die in der nächsten Stunde startet, eine Erinnerung
speichereAlsCsv schreibt alle Fahrten als CSV-Datei
protokolliere hängt einen Text mit Zeitstempel an protokoll an

a) Ordne jede Methode einem Belang (Ablauf, Fachlogik, Darstellung, Benachrichtigung, Speicherung, Protokollierung) und einer Person aus dem Szenario zu, die Änderungen daran verlangen würde. Begründe an einem Beispiel, warum zwei Methoden mit demselben Belang trotzdem in verschiedene Klassen gehören können. (6 Punkte)

Belange und Akteure der Fahrtenverwaltung (Beispiellösung)
Methode Belang Akteur
bieteAn, frageAn, bestaetige Ablauf mit Fachregeln (Kostenbeitrag, Einwilligung, freie Plätze, Probezeit) Thomas Becker (Projektleitung, Fahrtregeln), bei der Einwilligung Andrea Wolf
kostenbeitragCent Fachlogik Thomas Becker
monitorzeilen Darstellung mit Datenschutzregel (anonym) Andrea Wolf (Datenschutz)
sendeErinnerungen, Mails in frageAn/bestaetige Benachrichtigung Projektteam, Rückmeldungen der Nutzenden
speichereAlsCsv Speicherung Svenja Krämer (Schul-IT)
protokolliere Protokollierung (Querschnitt) Svenja Krämer, Andrea Wolf (Aufbewahrung)

Beispiel: kostenbeitragCent und die Prüfung der Einwilligung in frageAn sind beide Fachlogik. Die Kostenbeitragsregel ändert die Projektleitung, die Einwilligungsregel die Datenschutzbeauftragte; nach dem SRP gehören sie deshalb nicht zwingend in dieselbe Klasse. Andere begründete Zuordnungen sind gleichwertig.

Bewertung: Zuordnung der acht Methoden (4 Punkte, je zwei richtige 1 Punkt), Beispiel zu Belang und Verantwortung (2 Punkte).

b) Zeichne ein UML-Klassendiagramm für einen Entwurf, in dem jede Klasse eine Verantwortung hat. Fachregeln sollen in den Klassen stehen, die die Daten haben (Tell, don’t ask), und die Attribute von Fahrt sollen nicht mehr öffentlich sein. Attribute und Methoden, die sich gegenüber der Vorgabe nicht ändern, darfst Du weglassen; Beziehungen mit Multiplizitäten gehören dazu. (8 Punkte)

UML-Klassendiagramm nach dem Umbau: Fahrtenverwaltung mit bieteAn, frageAn und bestaetige kennt je einen FahrtenSpeicher und eine Benachrichtigung; FahrtenSpeicher aggregiert beliebig viele Fahrten; Fahrt hat private Attribute, eine Konstante HOECHSTBEITRAG_CENT_JE_KM und die Methoden nimmAnfrageAn, bestaetige, freiePlaetze, kostenbeitragCent und fahrerInProbezeit und kennt eine Person als Fahrer; Person hat zusätzlich darfMitfahren; Benachrichtigung mit Postausgang und den Methoden anfrageErhalten, mitfahrtBestaetigt und erinnere benutzt Fahrt; Monitorauswahl benutzt Fahrt und erzeugt Monitoreinträge mit Haltepunkt, Abfahrt und freien Plätzen (mit KI erstellt)

Die Protokollierung erledigt eine Bibliothek (java.util.logging bzw. logging), sie erscheint deshalb nicht als eigene Klasse. Wer sie als Klasse Protokoll einzeichnet, bekommt ebenfalls die volle Punktzahl.

Bewertung: Fahrt mit privaten Attributen und den Fachregeln freie Plätze, Kostenbeitrag, Probezeit (2 Punkte), Einwilligung als Frage an die Person oder an die Fahrt (1 Punkt), eigene Klassen für Benachrichtigung, Monitor und Speicherung (3 Punkte), schlanke Ablaufklasse (1 Punkt), Beziehungen mit Multiplizitäten und Abhängigkeiten in UML-Notation (1 Punkt).

c) Begründe zwei Schnittentscheidungen Deines Entwurfs: eine mit dem Single Responsibility Principle oder Separation of Concerns und eine mit Information Hiding. (4 Punkte)

  • SRP/SoC – eigene Monitorauswahl: Welche Daten ein Flurmonitor zeigen darf, bestimmt die Datenschutzbeauftragte. Steht diese Regel in einer eigenen Klasse, die nur Monitoreintrag-Objekte ohne Namen erzeugt, kann eine Änderung an Mails oder Fahrtregeln den Datenschutz nicht versehentlich brechen, und eine Prüfung durch Andrea Wolf betrifft genau eine Klasse (2 Punkte).

  • Information Hiding – private Attribute in Fahrt: Ob die Bestätigten als Liste, Menge oder Zähler gespeichert werden, ist ein Geheimnis der Fahrt. Außen gibt es nur freiePlaetze() und bestaetige(person); niemand kann einen Platz an der Prüfung vorbei belegen (2 Punkte).

Andere Begründungen, etwa FahrtenSpeicher verbirgt das Dateiformat, sind gleichwertig.

d) Beurteile Kohäsion und Kopplung der beiden Entwürfe. Gehe dabei auch auf eine Stelle ein, an der Dein neuer Entwurf mehr Kopplung hat als vorher. (4 Punkte)

  • Kohäsion: Vorher gering; die Methoden von Fahrtenverwaltung nutzen getrennte Attribute (postausgang nur die Benachrichtigungen, protokoll nur protokolliere) und ändern sich aus verschiedenen Gründen. Nachher hat jede Klasse eine Gruppe von Methoden, die gemeinsam mit denselben Daten arbeiten (1 Punkt).

  • Kopplung nach innen: Vorher kannte jede Methode die öffentlichen Attribute von Fahrt; jede Änderung an Fahrt konnte alle Methoden treffen. Nachher sind die anderen Klassen nur an die fachlichen Methoden von Fahrt gekoppelt (1 Punkt).

  • Mehr Kopplung: Die Zahl der Klassen und Beziehungen ist gestiegen: Benachrichtigung, Monitorauswahl und FahrtenSpeicher hängen alle von Fahrt ab, Fahrtenverwaltung kennt zwei weitere Klassen. Eine Änderung an der Schnittstelle von Fahrt betrifft jetzt mehrere Klassen (1 Punkt).

  • Urteil: Die zusätzliche Kopplung geht über schmale, fachliche Schnittstellen und ist deshalb weniger riskant als die frühere Kopplung an Rohdaten. Der Umbau lohnt sich, weil die Anforderungen aus mehreren Richtungen kommen (1 Punkt).

Aufgabe 3.2: Die Anzeige der Flurmonitore in Belange trennen (Profilkompetenz)

(ca. 18 min Bearbeitungszeit)

Die Flurmonitore des Mitfahrlabors zeigen offene Fahrtangebote. Bis die Datenbank angebunden ist, liest eine Übergangslösung die Angebote aus einer CSV-Datei mit den Spalten id;fahrer;haltepunkt;richtung;abfahrt;freie Plätze. Angezeigt werden alle Angebote mit freien Plätzen, die noch nicht abgefahren sind. Andrea Wolf, die Datenschutzbeauftragte, hat bei einer Prüfung bemerkt, dass im HTML der Name der fahrenden Person steht, zwar nicht sichtbar, aber als title-Attribut, das beim Überfahren mit der Maus erscheint.

package de.mitfahrlabor.monitor;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.LocalTime;

public class Monitorseite {

    public String erzeuge(Path datei, LocalTime jetzt) throws IOException {
        StringBuilder html = new StringBuilder("<ul class=\"monitor\">\n");
        int angezeigt = 0;
        for (String zeile : Files.readAllLines(datei, StandardCharsets.UTF_8)) {
            if (zeile.isBlank() || zeile.startsWith("#")) {
                continue;
            }
            String[] f = zeile.split(";");
            LocalTime abfahrt = LocalTime.parse(f[4]);
            int frei = Integer.parseInt(f[5]);
            if (frei > 0 && abfahrt.isAfter(jetzt)) {
                html.append("  <li title=\"Fahrt von ").append(f[1]).append("\">")
                    .append(f[2]).append(" → ").append(f[3]).append(", ").append(f[4]).append(" Uhr, ")
                    .append(frei).append(frei == 1 ? " Platz" : " Plätze").append("</li>\n");
                angezeigt = angezeigt + 1;
            }
        }
        html.append("</ul>");
        System.out.println(LocalDateTime.now() + " Monitor: " + angezeigt + " Angebote angezeigt");
        return html.toString();
    }
}

a) Ordne die Anweisungen der Methode erzeuge den Belangen Speicherung, Fachlogik, Darstellung und Protokollierung zu, und erkläre, warum der Datenschutzfehler bei dieser Aufteilung leicht passieren konnte. (4 Punkte)

  • Speicherung: Datei lesen, Kommentar- und Leerzeilen überspringen, split(";"), die Spaltenindizes f[1] bis f[5], Umwandlung von Uhrzeit und Zahl (1 Punkt).

  • Fachlogik: Auswahl frei > 0 && abfahrt.isAfter(jetzt) und die Regel, dass Namen nicht angezeigt werden (1 Punkt).

  • Darstellung: StringBuilder bzw. Textaufbau mit <ul>, <li>, Pfeil, „Uhr“, „Platz“/„Plätze“ (0,5 Punkte).

  • Protokollierung: Ausgabe mit Zeitstempel auf die Konsole (0,5 Punkte).

Der Fehler konnte passieren, weil die Darstellung direkt auf die Rohdaten (f[1]) zugreift. Die Datenschutzregel „ohne Namen“ steht nirgends ausdrücklich; sie wird nur dadurch eingehalten, dass jemand beim HTML-Bau den Namen nicht verwendet (1 Punkt).

b) Baue die Methode so um, dass jeder Belang in einer eigenen Klasse steht: eine Klasse liest die Datei und liefert Datensätze, eine Klasse wählt die anzuzeigenden Angebote aus und liefert dabei nur Einträge ohne Namen, eine Klasse erzeugt das HTML ohne title-Attribut, und das Protokoll schreibt ein Logger. Eine Klasse Monitorseite steuert den Ablauf. Schreibe einen Test, der für vier Angebote prüft, dass nur die richtigen zwei und keine Namen erscheinen. (10 Punkte)

// Angebotsdatensatz.java
package de.mitfahrlabor.monitor;

import java.time.LocalTime;

/** Ein gespeichertes Fahrtangebot, so wie es in der Datei steht. */
public record Angebotsdatensatz(String id, String fahrer, String haltepunkt, String richtung, LocalTime abfahrt, int freiePlaetze) {
}

// Monitoreintrag.java
package de.mitfahrlabor.monitor;

import java.time.LocalTime;

/** Was ein Flurmonitor zeigen darf: kein Name, keine ID. */
public record Monitoreintrag(String haltepunkt, String richtung, LocalTime abfahrt, int freiePlaetze) {
}

// AngebotsDatei.java
package de.mitfahrlabor.monitor;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;

/** Speicherung: kennt Dateiformat und Spaltenreihenfolge. */
public class AngebotsDatei {

    private final Path datei;

    public AngebotsDatei(Path datei) {
        this.datei = datei;
    }

    public List<Angebotsdatensatz> alle() throws IOException {
        List<Angebotsdatensatz> angebote = new ArrayList<>();
        for (String zeile : Files.readAllLines(datei, StandardCharsets.UTF_8)) {
            if (!zeile.isBlank() && !zeile.startsWith("#")) {
                angebote.add(alsDatensatz(zeile.split(";")));
            }
        }
        return angebote;
    }

    private static Angebotsdatensatz alsDatensatz(String[] f) {
        return new Angebotsdatensatz(f[0], f[1], f[2], f[3], LocalTime.parse(f[4]), Integer.parseInt(f[5]));
    }
}

// Monitorauswahl.java
package de.mitfahrlabor.monitor;

import java.time.LocalTime;
import java.util.List;

/** Fachlogik: welche Angebote ein Monitor zeigt, und zwar anonym. */
public class Monitorauswahl {

    public List<Monitoreintrag> offeneAngebote(List<Angebotsdatensatz> angebote, LocalTime jetzt) {
        return angebote.stream()
                .filter(a -> a.freiePlaetze() > 0 && a.abfahrt().isAfter(jetzt))
                .map(a -> new Monitoreintrag(a.haltepunkt(), a.richtung(), a.abfahrt(), a.freiePlaetze()))
                .toList();
    }
}

// MonitorAnsicht.java
package de.mitfahrlabor.monitor;

import java.util.List;

/** Darstellung: macht aus Monitoreinträgen HTML. */
public class MonitorAnsicht {

    public String alsHtml(List<Monitoreintrag> eintraege) {
        StringBuilder html = new StringBuilder("<ul class=\"monitor\">\n");
        for (Monitoreintrag e : eintraege) {
            html.append("  <li>").append(e.haltepunkt()).append(" → ").append(e.richtung()).append(", ")
                .append(e.abfahrt()).append(" Uhr, ").append(e.freiePlaetze())
                .append(e.freiePlaetze() == 1 ? " Platz" : " Plätze").append("</li>\n");
        }
        return html.append("</ul>").toString();
    }
}

// Monitorseite.java
package de.mitfahrlabor.monitor;

import java.io.IOException;
import java.time.LocalTime;
import java.util.List;
import java.util.logging.Logger;

/** Ablauf: lesen lassen, auswählen lassen, darstellen lassen, protokollieren. */
public class Monitorseite {

    private static final Logger LOG = Logger.getLogger(Monitorseite.class.getName());

    private final AngebotsDatei datei;
    private final Monitorauswahl auswahl = new Monitorauswahl();
    private final MonitorAnsicht ansicht = new MonitorAnsicht();

    public Monitorseite(AngebotsDatei datei) {
        this.datei = datei;
    }

    public String erzeuge(LocalTime jetzt) throws IOException {
        List<Monitoreintrag> eintraege = auswahl.offeneAngebote(datei.alle(), jetzt);
        LOG.info(() -> "Monitor: " + eintraege.size() + " Angebote angezeigt");
        return ansicht.alsHtml(eintraege);
    }
}

Test (Angebote von Ben Richter, Niklas Fischer, Marie Schulz und Jonas Wolff, Uhrzeit 06:55: Niklas hat keinen Platz mehr, Marie ist schon abgefahren):

package de.mitfahrlabor.monitor;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class MonitorseiteTest {

    static final List<String> ZEILEN = List.of(
            "# id;fahrer;haltepunkt;richtung;abfahrt;freie Plätze",
            "F-11;Ben Richter;Gütersloh ZOB;DBH;07:05;2",
            "F-12;Niklas Fischer;Bad Salzuflen Bahnhof;DBH;07:10;0",
            "F-13;Marie Schulz;Herford Bahnhof;DBH;06:50;1",
            "F-14;Jonas Wolff;Halle (Westf.) Bahnhof;DBH;07:20;1");

    static final LocalTime JETZT = LocalTime.of(6, 55);

    @Test
    void zeigtNurOffeneKuenftigeAngeboteOhneNamen(@TempDir Path ordner) throws IOException {
        Path datei = ordner.resolve("angebote.csv");
        Files.write(datei, ZEILEN, StandardCharsets.UTF_8);
        String html = new Monitorseite(new AngebotsDatei(datei)).erzeuge(JETZT);
        assertEquals("<ul class=\"monitor\">\n"
                + "  <li>Gütersloh ZOB → DBH, 07:05 Uhr, 2 Plätze</li>\n"
                + "  <li>Halle (Westf.) Bahnhof → DBH, 07:20 Uhr, 1 Platz</li>\n"
                + "</ul>", html);
        assertFalse(html.contains("Richter"));
    }

    @Test
    void auswahlOhneDateiTestbar() {
        List<Angebotsdatensatz> angebote = List.of(
                new Angebotsdatensatz("F-11", "Ben Richter", "Gütersloh ZOB", "DBH", LocalTime.of(7, 5), 2),
                new Angebotsdatensatz("F-12", "Niklas Fischer", "Bad Salzuflen Bahnhof", "DBH", LocalTime.of(7, 10), 0));
        assertEquals(List.of(new Monitoreintrag("Gütersloh ZOB", "DBH", LocalTime.of(7, 5), 2)),
                new Monitorauswahl().offeneAngebote(angebote, JETZT));
    }
}

Bewertung: Speicherung mit Datensatz-Typ (2 Punkte), Auswahl mit eigenem Eintragstyp ohne Namen (3 Punkte), Darstellung nur aus Einträgen (2 Punkte), Logger statt Konsolenausgabe (1 Punkt), Test mit zwei erwarteten Zeilen und Prüfung auf fehlende Namen (2 Punkte).

c) Erläutere, warum der Datenschutzfehler nach Deinem Umbau nicht mehr unbemerkt passieren kann, und ordne die Klasse Monitorauswahl nach SRP und nach SoC ein. (3 Punkte)

Die Darstellung bekommt nur Monitoreintrag-Objekte, die gar keinen Namen enthalten; sie kann ihn also nicht ausgeben, auch nicht versehentlich. Die Regel „anonym“ steht an genau einer Stelle und ist mit einem eigenen Test ohne Datei prüfbar (1,5 Punkte). Nach SoC gehört Monitorauswahl zur Fachlogik, nach SRP ist sie der Datenschutzbeauftragten gegenüber verantwortlich: Ändert Andrea Wolf die Regel, was angezeigt werden darf, ändert sich nur diese Klasse (1,5 Punkte).

d) Jonas Wolff aus dem Projektteam meint: „Vier Klassen und zwei Records für eine Übergangslösung, die in ein paar Wochen durch die Datenbank ersetzt wird? Das ist doch Overengineering.“ Nimm Stellung. (3 Punkte)

Der Einwand ist ernst zu nehmen: Für Code, der wirklich bald gelöscht wird, sind viele Klassen Aufwand ohne Nutzen (KISS, YAGNI) (1 Punkt). Hier spricht aber gerade die geplante Datenbank für die Trennung: Wird die Datei durch die View v_monitor ersetzt, muss nur AngebotsDatei ausgetauscht werden, Auswahl, Darstellung und Tests bleiben (1 Punkt). Außerdem hat die Trennung einen Datenschutzfehler behoben, der in der kurzen Fassung nicht aufgefallen war. Ein Kompromiss wäre, nur Auswahl und Darstellung zu trennen und Lesen und Ablauf zusammenzulassen (1 Punkt).


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Übungsaufgaben zum Schneiden von Klassen: Single Responsibility, Separation of Concerns, Information Hiding, Tell, don’t ask und Law of Demeter” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/klassen-schneiden-uebungsaufgaben veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/design-prinzipien/clean-code. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 10.11.2026.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: