Übungsaufgaben zum Erweitern statt Ändern: Open Closed Principle und Interface Segregation Principle

https://bildung.social/@oerinformatik/

https://oer-informatik.de/erweitern-statt-aendern-uebungsaufgaben

Clean Code, Open Closed Principle, Interface Segregation Principle, Polymorphie, Strategie, Interface, Protocol, YAGNI, Test-Double, UML-Klassendiagramm, Übungsaufgaben, Prüfungsvorbereitung

tl/dr; (Bearbeitungszeit insgesamt 63 min, mehrere Aufgaben): Drei Übungsaufgaben zum Erweitern statt Ändern: Du ersetzt im Mitfahrlabor eine Fallunterscheidung nach dem Fahrzeugtyp durch Polymorphie, baust in der CSB-Rad-Kata die Versandarten als Strategie um und ergänzt das Expresspaket, ohne den Bestellabschluss zu ändern, und teilst ein zu breites Interface des Mitfahrlabors nach den Rollen seiner Nutzenden auf, mit Klassendiagramm vor und nach dem Umbau [AI-Assisted]. (Zuletzt geändert am 10.11.2026)

Diese Übungsaufgaben gehören zum Artikel Erweitern statt ändern: Open Closed Principle und Interface Segregation Principle.

Wortwolke zu den Übungsaufgaben: Open Closed Principle, Interface Segregation Principle, Polymorphie, Strategie, Interface, Fallunterscheidung, Test-Double, Protocol, Rollen-Interface, UML-Klassendiagramm (mit KI erstellt)

Hinweise zur Bearbeitung: Als FIA musst Du zur Teil-2-Prüfung Aufgaben auf Kernkompetenz-Ebene lösen können, das ist Aufgabe 2.1. Von FIA wird zusätzlich erwartet, dass ihnen Aufgaben auf Profilkompetenz-Ebene (Aufgabe 3.1 und Aufgabe 3.2) keine Probleme bereiten. 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 alle drei Aufgaben 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, „Ermittle“ verlangt ein vollständiges Ergebnis (hier eine Tabelle), „Erläutere“ verlangt eine Erklärung mit Beispiel aus dem Code, „Baue um“, „Ergänze“ und „Schreibe“ verlangen lauffähigen Code, „Zeichne“ verlangt ein UML-Klassendiagramm, „Beurteile“ verlangt 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 „Erweitern statt Ändern: Open
Closed Principle und Interface Segregation Principle“.

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/erweitern-statt-aendern
- https://oer-informatik.de/vererbung-ersetzbarkeit
- https://oer-informatik.de/uml-klassendiagramm-interface-abstraktion
- https://oer-informatik.de/entwurfsmuster
- https://oer-informatik.de/refactoring
- https://oer-informatik.de/unit-tests
- https://oer-informatik.de/dependency-injection
- Stilbeispiel: https://oer-informatik.de/erweitern-statt-aendern-uebungsaufgaben

Die Aufgaben sollen Inhalte zu diesen Schlagwörtern abdecken:
- Clean Code
- Open Closed Principle
- Interface Segregation Principle
- Polymorphie
- Strategie
- Interface
- Protocol
- YAGNI
- Test-Double
- UML-Klassendiagramm

Kompetenzstufe: Profilkompetenz (alternativ: Basiskompetenz, Kernkompetenz, 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: Kostenbeitrag je Fahrzeugtyp im Mitfahrlabor (Kernkompetenz)

(ca. 17 min Bearbeitungszeit)

Die Mitfahr-App Mitfahrlabor berechnet für jedes Fahrtangebot den Kostenbeitrag, den jede mitfahrende Person zahlt. Er hängt vom Fahrzeug ab: Im PKW sind es 8 Cent je km, im Kleinbus 6 Cent je km und ab fünf Mitfahrenden 5 Cent je km, auf dem Motorrad 10 Cent je km. Auf einem Motorrad darf außerdem nur eine Person mitfahren. Die Regel des Szenarios (höchstens 0,10 € je km und Person) ist damit überall eingehalten. Der Code dazu steht in zwei Klassen:

// Paket de.mitfahrlabor, Stand vor dem Umbau
// ===== Fahrzeugtyp.java =====
package de.mitfahrlabor;

public enum Fahrzeugtyp { PKW, KLEINBUS, MOTORRAD }

// ===== Fahrtangebot.java =====
package de.mitfahrlabor;

/** Ein Angebot für eine einfache Fahrt; Entfernung in ganzen Kilometern. */
public record Fahrtangebot(Fahrzeugtyp fahrzeugtyp, int entfernungKm, int mitfahrende) {
}

// ===== Kostenbeitragsrechner.java =====
package de.mitfahrlabor;

/** Kostenbeitrag je mitfahrender Person in Cent, höchstens 10 Cent je km. */
public class Kostenbeitragsrechner {

    public int jePersonCent(Fahrtangebot angebot) {
        switch (angebot.fahrzeugtyp()) {
            case PKW:
                return angebot.entfernungKm() * 8;
            case KLEINBUS:
                int centJeKm = angebot.mitfahrende() >= 5 ? 5 : 6;
                return angebot.entfernungKm() * centJeKm;
            case MOTORRAD:
                return angebot.entfernungKm() * 10;
            default:
                throw new IllegalArgumentException("Unbekannter Fahrzeugtyp: " + angebot.fahrzeugtyp());
        }
    }
}

// ===== Angebotspruefung.java =====
package de.mitfahrlabor;

/** Prüft ein Angebot, bevor es veröffentlicht wird. */
public class Angebotspruefung {

    public void pruefe(Fahrtangebot angebot) {
        if (angebot.mitfahrende() < 1) {
            throw new IllegalArgumentException("Mindestens eine Person muss mitfahren");
        }
        switch (angebot.fahrzeugtyp()) {
            case MOTORRAD:
                if (angebot.mitfahrende() > 1) {
                    throw new IllegalArgumentException("Auf dem Motorrad fährt nur eine Person mit");
                }
                break;
            case PKW:
            case KLEINBUS:
                break;
            default:
                throw new IllegalArgumentException("Unbekannter Fahrzeugtyp: " + angebot.fahrzeugtyp());
        }
    }
}

Die Tests dazu sind grün:

// ===== KostenbeitragTest.java =====
package de.mitfahrlabor;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;

class KostenbeitragTest {

    private final Kostenbeitragsrechner rechner = new Kostenbeitragsrechner();
    private final Angebotspruefung pruefung = new Angebotspruefung();

    @Test
    void pkwAchtCentJeKm() {
        assertEquals(144, rechner.jePersonCent(new Fahrtangebot(Fahrzeugtyp.PKW, 18, 3)));
    }

    @Test
    void kleinbusGuenstigerAbFuenfMitfahrenden() {
        assertEquals(108, rechner.jePersonCent(new Fahrtangebot(Fahrzeugtyp.KLEINBUS, 18, 4)));
        assertEquals(90, rechner.jePersonCent(new Fahrtangebot(Fahrzeugtyp.KLEINBUS, 18, 5)));
    }

    @Test
    void motorradZehnCentJeKm() {
        assertEquals(130, rechner.jePersonCent(new Fahrtangebot(Fahrzeugtyp.MOTORRAD, 13, 1)));
    }

    @Test
    void motorradNimmtNurEinePersonMit() {
        assertDoesNotThrow(() -> pruefung.pruefe(new Fahrtangebot(Fahrzeugtyp.MOTORRAD, 13, 1)));
        assertThrows(IllegalArgumentException.class,
                () -> pruefung.pruefe(new Fahrtangebot(Fahrzeugtyp.MOTORRAD, 13, 2)));
    }

    @Test
    void ohneMitfahrendeAbgelehnt() {
        assertThrows(IllegalArgumentException.class,
                () -> pruefung.pruefe(new Fahrtangebot(Fahrzeugtyp.PKW, 18, 0)));
    }
}

a) Nenne alle Stellen, die geändert werden müssen, wenn ein neuer Fahrzeugtyp dazukommt, und erläutere, welches Risiko entsteht, wenn eine davon vergessen wird. (3 Punkte)

Zu ändern sind die Enumeration Fahrzeugtyp, die Fallunterscheidung in Kostenbeitragsrechner.jePersonCent und die Fallunterscheidung in Angebotspruefung.pruefe (1 Punkt für alle drei Stellen). Vergisst man eine der beiden Fallunterscheidungen, meldet der Compiler nichts. Erst zur Laufzeit wirft der default-Zweig bzw. der else-Zweig „Unbekannter Fahrzeugtyp“, und zwar je nach vergessener Stelle beim Veröffentlichen oder erst beim Berechnen des Kostenbeitrags (1 Punkt). Weil dieselbe Fallunterscheidung in zwei Klassen steht, verteilt sich eine fachliche Erweiterung auf mehrere Klassen (Shotgun Surgery); der Code ist nicht geschlossen gegenüber neuen Fahrzeugtypen (1 Punkt).

b) Baue den Code so um, dass jeder Fahrzeugtyp seine Regeln in einer eigenen Klasse hinter einer gemeinsamen Schnittstelle hat und weder Kostenbeitragsrechner noch Angebotspruefung eine Fallunterscheidung nach dem Fahrzeugtyp enthält. Die Tests müssen ohne Änderung grün bleiben. (8 Punkte)

Die Schnittstelle Fahrzeugregel fasst die beiden Operationen zusammen, die bisher in den Fallunterscheidungen standen. Je Fahrzeugtyp gibt es eine Klasse. Fahrzeugregeln ist die einzige Stelle, die Fahrzeugtyp und Regel zusammenbringt. Fahrzeugtyp und Fahrtangebot bleiben unverändert.

// Paket de.mitfahrlabor nach dem Umbau (Fahrzeugtyp und Fahrtangebot unverändert)
// ===== Fahrzeugregel.java =====
package de.mitfahrlabor;

/** Regeln, die vom Fahrzeugtyp abhängen; je Fahrzeugtyp eine Klasse. */
public interface Fahrzeugregel {

    int kostenbeitragJePersonCent(Fahrtangebot angebot);

    /** Wirft IllegalArgumentException, wenn das Angebot mit diesem Fahrzeug nicht möglich ist. */
    void pruefe(Fahrtangebot angebot);
}

// ===== PkwRegel.java =====
package de.mitfahrlabor;

public class PkwRegel implements Fahrzeugregel {

    static final int CENT_JE_KM = 8;

    @Override
    public int kostenbeitragJePersonCent(Fahrtangebot angebot) {
        return angebot.entfernungKm() * CENT_JE_KM;
    }

    @Override
    public void pruefe(Fahrtangebot angebot) {
        // keine Einschränkung über die Sitzplätze hinaus
    }
}

// ===== KleinbusRegel.java =====
package de.mitfahrlabor;

public class KleinbusRegel implements Fahrzeugregel {

    static final int CENT_JE_KM = 6;
    static final int CENT_JE_KM_AB_GRUPPE = 5;
    static final int GRUPPE_AB_MITFAHRENDEN = 5;

    @Override
    public int kostenbeitragJePersonCent(Fahrtangebot angebot) {
        int centJeKm = angebot.mitfahrende() >= GRUPPE_AB_MITFAHRENDEN ? CENT_JE_KM_AB_GRUPPE : CENT_JE_KM;
        return angebot.entfernungKm() * centJeKm;
    }

    @Override
    public void pruefe(Fahrtangebot angebot) {
        // keine Einschränkung über die Sitzplätze hinaus
    }
}

// ===== MotorradRegel.java =====
package de.mitfahrlabor;

public class MotorradRegel implements Fahrzeugregel {

    static final int CENT_JE_KM = 10;

    @Override
    public int kostenbeitragJePersonCent(Fahrtangebot angebot) {
        return angebot.entfernungKm() * CENT_JE_KM;
    }

    @Override
    public void pruefe(Fahrtangebot angebot) {
        if (angebot.mitfahrende() > 1) {
            throw new IllegalArgumentException("Auf dem Motorrad fährt nur eine Person mit");
        }
    }
}

// ===== Fahrzeugregeln.java =====
package de.mitfahrlabor;

import java.util.EnumMap;
import java.util.Map;

/** Einzige Stelle, die Fahrzeugtypen und Regeln zusammenbringt. */
public class Fahrzeugregeln {

    private final Map<Fahrzeugtyp, Fahrzeugregel> nachTyp = new EnumMap<>(Fahrzeugtyp.class);

    public Fahrzeugregeln() {
        nachTyp.put(Fahrzeugtyp.PKW, new PkwRegel());
        nachTyp.put(Fahrzeugtyp.KLEINBUS, new KleinbusRegel());
        nachTyp.put(Fahrzeugtyp.MOTORRAD, new MotorradRegel());
    }

    public Fahrzeugregel fuer(Fahrzeugtyp typ) {
        Fahrzeugregel regel = nachTyp.get(typ);
        if (regel == null) {
            throw new IllegalArgumentException("Keine Regel für Fahrzeugtyp: " + typ);
        }
        return regel;
    }
}

// ===== Kostenbeitragsrechner.java =====
package de.mitfahrlabor;

/** Kostenbeitrag je mitfahrender Person in Cent, höchstens 10 Cent je km. */
public class Kostenbeitragsrechner {

    private final Fahrzeugregeln regeln = new Fahrzeugregeln();

    public int jePersonCent(Fahrtangebot angebot) {
        return regeln.fuer(angebot.fahrzeugtyp()).kostenbeitragJePersonCent(angebot);
    }
}

// ===== Angebotspruefung.java =====
package de.mitfahrlabor;

/** Prüft ein Angebot, bevor es veröffentlicht wird. */
public class Angebotspruefung {

    private final Fahrzeugregeln regeln = new Fahrzeugregeln();

    public void pruefe(Fahrtangebot angebot) {
        if (angebot.mitfahrende() < 1) {
            throw new IllegalArgumentException("Mindestens eine Person muss mitfahren");
        }
        regeln.fuer(angebot.fahrzeugtyp()).pruefe(angebot);
    }
}

Die fünf Tests bleiben unverändert grün.

Bewertung: Schnittstelle mit beiden Operationen (2 Punkte), drei Regelklassen mit richtigen Werten, Konstanten statt Zahlen im Code (3 Punkte), Zuordnung an einer Stelle mit Ablehnung unbekannter Typen (2 Punkte), Rechner und Prüfung ohne Fallunterscheidung, Tests grün (1 Punkt). Gleichwertig ist eine Lösung, in der die Java-Enumeration selbst die Regeln trägt (eine abstrakte Methode je Operation und eine Implementierung je Konstante); sie ist kompakter, bündelt aber alle Regeln wieder in einer Datei.

c) Das Projektteam möchte Fahrten mit Elektroautos fördern: Im Elektroauto beträgt der Kostenbeitrag 5 Cent je km und Person, sonst gilt keine Einschränkung. Ergänze den Fahrzeugtyp ELEKTROAUTO und schreibe einen Test dafür. Gib an, welche vorhandenen Klassen Du dafür ändern musstest. (3 Punkte)

// ===== Fahrzeugtyp.java =====
package de.mitfahrlabor;

public enum Fahrzeugtyp { PKW, KLEINBUS, MOTORRAD, ELEKTROAUTO }

// ===== ElektroautoRegel.java =====
package de.mitfahrlabor;

/** Neu: Elektroauto, 5 Cent je km und Person; sonst keine Einschränkung. */
public class ElektroautoRegel implements Fahrzeugregel {

    static final int CENT_JE_KM = 5;

    @Override
    public int kostenbeitragJePersonCent(Fahrtangebot angebot) {
        return angebot.entfernungKm() * CENT_JE_KM;
    }

    @Override
    public void pruefe(Fahrtangebot angebot) {
        // keine Einschränkung über die Sitzplätze hinaus
    }
}

// ===== Fahrzeugregeln.java (Konstruktor, eine Zeile mehr) =====
    public Fahrzeugregeln() {
        nachTyp.put(Fahrzeugtyp.PKW, new PkwRegel());
        nachTyp.put(Fahrzeugtyp.KLEINBUS, new KleinbusRegel());
        nachTyp.put(Fahrzeugtyp.MOTORRAD, new MotorradRegel());
        nachTyp.put(Fahrzeugtyp.ELEKTROAUTO, new ElektroautoRegel());   // neu
    }

// ===== ElektroautoTest.java =====
package de.mitfahrlabor;

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

import org.junit.jupiter.api.Test;

class ElektroautoTest {

    @Test
    void elektroautoFuenfCentJeKm() {
        Fahrtangebot angebot = new Fahrtangebot(Fahrzeugtyp.ELEKTROAUTO, 20, 3);
        assertDoesNotThrow(() -> new Angebotspruefung().pruefe(angebot));
        assertEquals(100, new Kostenbeitragsrechner().jePersonCent(angebot));
    }
}

Geändert werden nur die Enumeration Fahrzeugtyp und die Zuordnung in Fahrzeugregeln (eine Zeile); Kostenbeitragsrechner, Angebotspruefung und die drei anderen Regeln bleiben unverändert. Bei 20 km ergibt sich ein Kostenbeitrag von 20 · 5 Cent = 100 Cent. Mit dem Code aus der Ausgangslage schlägt derselbe Test fehl: Angebotspruefung.pruefe wirft „Unbekannter Fahrzeugtyp: ELEKTROAUTO“.

Bewertung: neue Klasse mit richtiger Berechnung (1 Punkt), Eintrag in Enumeration und Zuordnung (1 Punkt), Test mit richtigem Erwartungswert und Angabe der geänderten Klassen (1 Punkt).

d) Ein Teammitglied meint: „Für drei Fahrzeugtypen mit einem Satz je km hätte doch eine Tabelle mit den Cent-Werten gereicht. Die vielen kleinen Klassen sind übertrieben.“ Beurteile den Einwand mit Blick auf YAGNI und das Open Closed Principle. (4 Punkte)

Der Einwand hat einen wahren Kern: Wenn sich die Fahrzeugtypen nur durch einen Zahlenwert unterscheiden, ist eine Tabelle (z. B. eine EnumMap bzw. ein dict von Typ zu Cent je km) einfacher als eine Klassenhierarchie, und eine Abstraktion ohne Bedarf wäre spekulative Allgemeinheit (YAGNI, KISS) (1 Punkt). Hier unterscheiden sich die Typen aber in ihrem Verhalten: Der Kleinbus hat eine Gruppenregel, das Motorrad eine eigene Prüfung. Eine Tabelle müsste dafür doch wieder Fallunterscheidungen enthalten (1 Punkt). Außerdem gab es die Fallunterscheidung schon an zwei Stellen, und mit dem Elektroauto kam eine Erweiterung tatsächlich; die Änderungsachse „neuer Fahrzeugtyp“ ist also belegt, das OCP zahlt sich aus (1 Punkt). Fazit: Für die jetzige Lage ist die Strategie angemessen. Kämen nur noch reine Sätze je km ohne eigene Regeln dazu, könnte man einfache Typen auch als Daten (Satz je km) in einer gemeinsamen Klasse abbilden und Klassen nur für Typen mit eigenem Verhalten anlegen (1 Punkt für eine begründete eigene Stellungnahme).

Aufgabe 3.1: Versandarten der Kata als Strategie (Profilkompetenz)

(ca. 25 min Bearbeitungszeit)

Die CSB-Rad GmbH (siehe Szenario CSB-Rad) möchte ein Expresspaket anbieten: Es gelten die Paketregeln (keine Räder, je angefangene 31,5 kg ein Paket zu 6,90 €), dazu kommen 9,90 € Zuschlag, und es gibt keine Freigrenze. Die Bestätigungsmail nennt als letzte Zeile „Versand: Expresspaket“. Du startest mit Stand 07 der Code-Kata; im Begleit-Repository liegt er unter src/kata/java/stand-07 bzw. src/kata/python/stand-07. Der vollständige Code:

// Kata CSB-Rad, Stand 07, Paket de.csbrad.shop (src/main/java)
// ===== 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) {
}

// ===== 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 Versandarten der CSB-Rad GmbH und verteilt auf die jeweiligen Regeln. */
public class Versandkostenrechner {

    private static final Set<String> VERSANDARTEN = Set.of("PAKET", "SPEDITION", "ABHOLUNG", "KURIER");
    private static final int SPEDITION_JE_RAD_CENT = 4900;

    private final Paketversand paketversand = new Paketversand();
    private final Kurierzustellung kurierzustellung = new Kurierzustellung();

    public void pruefeVersandmoeglich(Bestellung bestellung) {
        if (!VERSANDARTEN.contains(bestellung.versandart())) {
            throw new IllegalArgumentException("Unbekannte Versandart: " + bestellung.versandart());
        }
        switch (bestellung.versandart()) {
            case "PAKET" -> paketversand.pruefe(bestellung);
            case "KURIER" -> kurierzustellung.pruefe(bestellung);
            default -> { }
        }
    }

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

// ===== Paketversand.java =====
package de.csbrad.shop;

/** Versandregeln für Pakete: Pauschale je angefangene 31,5 kg, frei ab 100 Euro Warenwert nach Rabatt. */
public class Paketversand {

    static final int PAKETPAUSCHALE_CENT = 690;
    static final int HOECHSTGEWICHT_PAKET_GRAMM = 31500;
    static final int VERSANDKOSTENFREI_AB_CENT = 10000;

    public void pruefe(Bestellung bestellung) {
        if (bestellung.enthaeltRad()) {
            throw new IllegalArgumentException("Räder können nicht als Paket versandt werden");
        }
    }

    public int anzahlPakete(Bestellung bestellung) {
        return Math.max(1, (bestellung.gesamtgewichtGramm() + HOECHSTGEWICHT_PAKET_GRAMM - 1) / HOECHSTGEWICHT_PAKET_GRAMM);
    }

    public int kosten(Bestellung bestellung) {
        if (bestellung.warenwertNachRabatt() >= VERSANDKOSTENFREI_AB_CENT) {
            return 0;
        }
        return anzahlPakete(bestellung) * PAKETPAUSCHALE_CENT;
    }
}

// ===== Kurierzustellung.java =====
package de.csbrad.shop;

/**
 * Neu in Stand 07: Zustellung per Lastenradkurier im Stadtgebiet, Pauschale, höchstens 25 kg, keine Räder.
 * Bewusst KEINE Unterklasse von Paketversand: Sie würde die Vorbedingung verschärfen (25 kg statt beliebig vieler Pakete)
 * und damit das Liskovsche Substitutionsprinzip verletzen. Gemeinsames (Rad-Prüfung) wird per Komposition genutzt.
 */
public class Kurierzustellung {

    static final int KURIERPAUSCHALE_CENT = 1290;
    static final int HOECHSTGEWICHT_KURIER_GRAMM = 25000;

    private final Paketversand paketregeln = new Paketversand();

    public void pruefe(Bestellung bestellung) {
        paketregeln.pruefe(bestellung);
        if (bestellung.gesamtgewichtGramm() > HOECHSTGEWICHT_KURIER_GRAMM) {
            throw new IllegalArgumentException("Kurierzustellung nur bis 25 kg");
        }
    }

    public int kosten(Bestellung bestellung) {
        return KURIERPAUSCHALE_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";
            case "KURIER" -> "Versand: Kurier (Lastenrad)";
            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;
    }
}

Die Tests von Stand 07:

// Kata CSB-Rad, Stand 07, src/test/java/de/csbrad/shop
// ===== BestellabschlussTest.java =====
package de.csbrad.shop;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

/**
 * Charakterisierungstests (seit Stand 04), angepasst an die Klassen aus Stand 06.
 * betraege(): {Warenwert, Rabatt, Versandkosten, Mehrwertsteuer, Gesamtbetrag} in Cent.
 */
class BestellabschlussTest {

    static final Position LICHTSET = new Position("LIC-SET-01", "Lichtset Nabendynamo StVZO", 7563, 1, 350, false);
    static final Position BREMSE = new Position("BRE-HYD-01", "Hydraulische Scheibenbremse (Paar)", 13361, 1, 750, false);
    static final Position BUEGELSCHLOSS = new Position("SCH-BUE-01", "Bügelschloss, gleichschließend", 6218, 1, 1300, false);
    static final Position RAHMENSCHLOSS = new Position("SCH-RAH-01", "Rahmenschloss, gleichschließend", 4118, 1, 450, false);
    static final Position STADTRAD = new Position("RAD-CITY-01", "Stadtrad „Promenade“ 7-Gang", 67143, 1, 16800, true);
    static final Position EBIKE = new Position("RAD-EB-02", "E-Bike „Kanal“ Mittelmotor 500 Wh", 252017, 1, 24500, true);
    static final String MAIL = "mira.engel@example.org";

    private Bestellabschluss abschluss;

    @BeforeEach
    void neuerAbschluss() {
        abschluss = new Bestellabschluss();
    }

    static int[] betraege(Bestellung b) {
        return new int[] {b.warenwert(), b.rabatt(), b.versandkosten(), b.mehrwertsteuer(), b.gesamtbetrag()};
    }

    static Position menge(Position p, int m) {
        return new Position(p.artikelnr(), p.bezeichnung(), p.nettopreisCent(), m, p.gewichtGramm(), p.istRad());
    }

    @Test
    void paketUnterFreigrenze() {
        assertArrayEquals(new int[] {7563, 0, 690, 1568, 9821}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "PAKET", null, MAIL))));
    }

    @Test
    void paketAbFreigrenzeIstVersandfrei() {
        assertArrayEquals(new int[] {13361, 0, 0, 2539, 15900}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BREMSE), "PAKET", null, MAIL))));
    }

    @Test
    void freigrenzeGiltNachRabatt() {
        assertArrayEquals(new int[] {10336, 1034, 690, 1898, 11890}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BUEGELSCHLOSS, RAHMENSCHLOSS), "PAKET", "KURIER10", MAIL))));
    }

    @Test
    void ohneRabattUeberFreigrenze() {
        assertArrayEquals(new int[] {10336, 0, 0, 1964, 12300}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BUEGELSCHLOSS, RAHMENSCHLOSS), "PAKET", null, MAIL))));
    }

    @Test
    void schwerePaketbestellungMitRabatt() {
        assertArrayEquals(new int[] {155450, 15545, 0, 26582, 166487}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(menge(BUEGELSCHLOSS, 25)), "PAKET", "KURIER10", MAIL))));
    }

    @Test
    void speditionJeRad() {
        assertArrayEquals(new int[] {149412, 0, 9800, 30250, 189462}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(menge(STADTRAD, 2), menge(LICHTSET, 2)), "SPEDITION", null, MAIL))));
    }

    @Test
    void speditionOhneRadKostetEinePauschale() {
        assertArrayEquals(new int[] {7563, 0, 4900, 2368, 14831}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "SPEDITION", null, MAIL))));
    }

    @Test
    void abholungMitRabattcode() {
        assertArrayEquals(new int[] {252017, 25202, 0, 43095, 269910}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(EBIKE), "ABHOLUNG", "KURIER10", MAIL))));
    }

    @Test
    void unbekannterCodeGibtKeinenRabatt() {
        assertArrayEquals(new int[] {7563, 0, 690, 1568, 9821}, betraege(abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "PAKET", "SOMMER20", MAIL))));
    }

    @Test
    void radAlsPaketWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> abschluss.abschliessen(new Bestellanfrage(List.of(STADTRAD), "PAKET", null, MAIL)));
        assertEquals(0, abschluss.mailversand().postausgang().size());
        assertEquals(0, abschluss.repository().alle().size());
    }

    @Test
    void leereBestellungWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> abschluss.abschliessen(new Bestellanfrage(List.of(), "PAKET", null, MAIL)));
        assertEquals(0, abschluss.mailversand().postausgang().size());
    }

    @Test
    void mengeNullWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> abschluss.abschliessen(new Bestellanfrage(List.of(menge(LICHTSET, 0)), "PAKET", null, MAIL)));
    }

    @Test
    void unbekannteVersandartWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class, () -> abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "BRIEF", null, MAIL)));
    }

    @Test
    void mailFuerPaketversand() {
        abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "PAKET", null, MAIL));
        assertEquals("An: mira.engel@example.org\nBestellung CSB-1001\nGesamtbetrag: 98,21 EUR\nVersand: Paket", abschluss.mailversand().postausgang().get(0));
    }

    @Test
    void mailFuerAbholung() {
        abschluss.abschliessen(new Bestellanfrage(List.of(EBIKE), "ABHOLUNG", "KURIER10", MAIL));
        assertEquals("An: mira.engel@example.org\nBestellung CSB-1001\nGesamtbetrag: 2699,10 EUR\nAbholung in der Werkstatt Bielefeld-Sennestadt",
                abschluss.mailversand().postausgang().get(0));
    }

    @Test
    void bestellungenWerdenFortlaufendNummeriertUndGespeichert() {
        abschluss.abschliessen(new Bestellanfrage(List.of(LICHTSET), "PAKET", null, MAIL));
        abschluss.abschliessen(new Bestellanfrage(List.of(BREMSE), "PAKET", null, MAIL));
        assertEquals(2, abschluss.repository().alle().size());
        assertEquals("CSB-1001", abschluss.repository().alle().get(0).bestellnummer());
        assertEquals("CSB-1002", abschluss.repository().alle().get(1).bestellnummer());
        assertEquals(15900, abschluss.repository().alle().get(1).gesamtbetrag());
    }

    // ---- neue Anforderung in Stand 07: Kurierzustellung ----

    @Test
    void kurierKostetPauschale() {
        assertArrayEquals(new int[] {19579, 0, 1290, 3965, 24834},
                betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BREMSE, BUEGELSCHLOSS), "KURIER", null, MAIL))));
        assertEquals("An: mira.engel@example.org\nBestellung CSB-1001\nGesamtbetrag: 248,34 EUR\nVersand: Kurier (Lastenrad)",
                abschluss.mailversand().postausgang().get(0));
    }

    @Test
    void kurierUeber25KiloWirdAbgelehnt() {
        assertThrows(IllegalArgumentException.class,
                () -> abschluss.abschliessen(new Bestellanfrage(List.of(menge(BUEGELSCHLOSS, 20)), "KURIER", null, MAIL)));
        assertEquals(0, abschluss.repository().alle().size());
    }

    @Test
    void kurierNimmtKeineRaeder() {
        assertThrows(IllegalArgumentException.class,
                () -> abschluss.abschliessen(new Bestellanfrage(List.of(STADTRAD), "KURIER", null, MAIL)));
    }
}

a) Nenne alle Stellen in Stand 07, die für die Versandart EXPRESS geändert werden müssten, wenn man den Entwurf beibehält, und erläutere, warum das dem Open Closed Principle widerspricht. (4 Punkte)

Geändert werden müssten: die Menge VERSANDARTEN im Versandkostenrechner, die Fallunterscheidung in pruefeVersandmoeglich (Räder ablehnen), die Fallunterscheidung in berechne (Kosten) und die Fallunterscheidung in Mailversand.versandzeile (2 Punkte für alle vier Stellen, 1 Punkt für zwei bis drei). Das OCP verlangt, dass neue Varianten hinzukommen, ohne fertigen, getesteten Code zu ändern. Hier verlangt jede neue Versandart Änderungen an zwei vorhandenen Klassen, und eine vergessene Stelle fällt nicht beim Übersetzen auf: Ohne neuen Fall im Mailversand landet das Expresspaket im default-Zweig, und die Mail fordert zur Abholung in der Werkstatt auf (2 Punkte).

b) Schreibe Tests für die neue Versandart: Expresspaket für eine Bremse samt Mailzeile, Expresspaket für 25 Bügelschlösser (zwei Pakete) und Ablehnung eines Rades. Berechne die erwarteten Werte vorher selbst. (3 Punkte)

Bremse: Warenwert 133,61 €, Versand 6,90 € + 9,90 € = 16,80 € (keine Freigrenze), Mehrwertsteuer 19 % von 150,41 € = 28,58 €, Gesamtbetrag 178,99 €. 25 Bügelschlösser wiegen 25 · 1,3 kg = 32,5 kg, also zwei Pakete: 2 · 6,90 € + 9,90 € = 23,70 €.

// Auszug aus src/kata/java/stand-08, BestellabschlussTest
    // ---- neue Anforderung in Stand 08: Expresspaket ----

    @Test
    void expressOhneFreigrenze() {
        assertArrayEquals(new int[] {13361, 0, 1680, 2858, 17899},
                betraege(abschluss.abschliessen(new Bestellanfrage(List.of(BREMSE), "EXPRESS", null, MAIL))));
        assertEquals("An: mira.engel@example.org\nBestellung CSB-1001\nGesamtbetrag: 178,99 EUR\nVersand: Expresspaket",
                abschluss.mailversand().postausgang().get(0));
    }

    @Test
    void expressJePaket() {
        // 25 Bügelschlösser = 32,5 kg = 2 Pakete
        assertEquals(2 * 690 + 990, abschluss.abschliessen(new Bestellanfrage(List.of(menge(BUEGELSCHLOSS, 25)), "EXPRESS", null, MAIL)).versandkosten());
    }

    @Test
    void expressNimmtKeineRaeder() {
        assertThrows(IllegalArgumentException.class,
                () -> abschluss.abschliessen(new Bestellanfrage(List.of(STADTRAD), "EXPRESS", null, MAIL)));
    }

Bewertung: je Test 1 Punkt (richtige Erwartungswerte, Mailzeile im ersten Test).

c) Baue den Versandteil so um, dass jede Versandart eine eigene Klasse hinter einer Schnittstelle Versandart mit pruefe, kosten und mailzeile ist und Bestellung und Mailversand keine Fallunterscheidung nach der Versandart mehr enthalten. Die Zuordnung von der Kennung zur Versandart soll an genau einer Stelle stehen. Ergänze danach das Expresspaket als neue Klasse, sodass alle alten und neuen Tests grün sind. (12 Punkte)

Vorgehen in kleinen Schritten, nach jedem Schritt alle Tests ausführen: Interface Versandart extrahieren und von Paketversand und Kurierzustellung realisieren lassen; Speditionsversand und Abholung anlegen; Zuordnung Versandarten einführen; Bestellung hält ein Versandart-Objekt statt der Kennung; Mailversand fragt mailzeile() ab; Versandkostenrechner löschen. Erst danach Expressversand anlegen und in Versandarten eintragen.

// Kata CSB-Rad, Stand 08: neue und geänderte Klassen (Versandkostenrechner entfällt)
// ===== Versandart.java =====
package de.csbrad.shop;

/** Strategie: jede Versandart prüft, berechnet und beschreibt sich selbst (OCP). */
public interface Versandart {

    /** Wirft IllegalArgumentException, wenn die Bestellung so nicht versandt werden kann. */
    void pruefe(Bestellung bestellung);

    int kosten(Bestellung bestellung);

    String mailzeile();
}

// ===== Paketversand.java =====
package de.csbrad.shop;

/** Pakete: Pauschale je angefangene 31,5 kg, frei ab 100 Euro Warenwert nach Rabatt, keine Räder. */
public class Paketversand implements Versandart {

    static final int PAKETPAUSCHALE_CENT = 690;
    static final int HOECHSTGEWICHT_PAKET_GRAMM = 31500;
    static final int VERSANDKOSTENFREI_AB_CENT = 10000;

    @Override
    public void pruefe(Bestellung bestellung) {
        if (bestellung.enthaeltRad()) {
            throw new IllegalArgumentException("Räder können nicht als Paket versandt werden");
        }
    }

    public int anzahlPakete(Bestellung bestellung) {
        return Math.max(1, (bestellung.gesamtgewichtGramm() + HOECHSTGEWICHT_PAKET_GRAMM - 1) / HOECHSTGEWICHT_PAKET_GRAMM);
    }

    @Override
    public int kosten(Bestellung bestellung) {
        if (bestellung.warenwertNachRabatt() >= VERSANDKOSTENFREI_AB_CENT) {
            return 0;
        }
        return anzahlPakete(bestellung) * PAKETPAUSCHALE_CENT;
    }

    @Override
    public String mailzeile() {
        return "Versand: Paket";
    }
}

// ===== Speditionsversand.java =====
package de.csbrad.shop;

/** Spedition: Pauschale je Rad; ohne Rad eine Pauschale. */
public class Speditionsversand implements Versandart {

    static final int SPEDITION_JE_RAD_CENT = 4900;

    @Override
    public void pruefe(Bestellung bestellung) {
        // per Spedition geht alles
    }

    @Override
    public int kosten(Bestellung bestellung) {
        return Math.max(1, bestellung.anzahlRaeder()) * SPEDITION_JE_RAD_CENT;
    }

    @Override
    public String mailzeile() {
        return "Versand: Spedition";
    }
}

// ===== Abholung.java =====
package de.csbrad.shop;

/** Abholung in der Werkstatt: kostenlos. */
public class Abholung implements Versandart {

    @Override
    public void pruefe(Bestellung bestellung) {
        // abholen lässt sich alles
    }

    @Override
    public int kosten(Bestellung bestellung) {
        return 0;
    }

    @Override
    public String mailzeile() {
        return "Abholung in der Werkstatt Bielefeld-Sennestadt";
    }
}

// ===== Kurierzustellung.java =====
package de.csbrad.shop;

/**
 * Neu in Stand 07: Zustellung per Lastenradkurier im Stadtgebiet, Pauschale, höchstens 25 kg, keine Räder.
 * Bewusst KEINE Unterklasse von Paketversand: Sie würde die Vorbedingung verschärfen (25 kg statt beliebig vieler Pakete)
 * und damit das Liskovsche Substitutionsprinzip verletzen. Gemeinsames (Rad-Prüfung) wird per Komposition genutzt.
 */
public class Kurierzustellung implements Versandart {

    static final int KURIERPAUSCHALE_CENT = 1290;
    static final int HOECHSTGEWICHT_KURIER_GRAMM = 25000;

    private final Paketversand paketregeln = new Paketversand();

    @Override
    public void pruefe(Bestellung bestellung) {
        paketregeln.pruefe(bestellung);
        if (bestellung.gesamtgewichtGramm() > HOECHSTGEWICHT_KURIER_GRAMM) {
            throw new IllegalArgumentException("Kurierzustellung nur bis 25 kg");
        }
    }

    @Override
    public int kosten(Bestellung bestellung) {
        return KURIERPAUSCHALE_CENT;
    }

    @Override
    public String mailzeile() {
        return "Versand: Kurier (Lastenrad)";
    }
}

// ===== Expressversand.java =====
package de.csbrad.shop;

/**
 * Neu in Stand 08: Expresspaket = Paketregeln plus Zuschlag, ohne Freigrenze.
 * Neue Klasse, kein vorhandener Code geändert (außer einer Zeile in Versandarten): Open-Closed-Prinzip.
 */
public class Expressversand implements Versandart {

    static final int EXPRESSZUSCHLAG_CENT = 990;

    private final Paketversand paketregeln = new Paketversand();

    @Override
    public void pruefe(Bestellung bestellung) {
        paketregeln.pruefe(bestellung);
    }

    @Override
    public int kosten(Bestellung bestellung) {
        return paketregeln.anzahlPakete(bestellung) * Paketversand.PAKETPAUSCHALE_CENT + EXPRESSZUSCHLAG_CENT;
    }

    @Override
    public String mailzeile() {
        return "Versand: Expresspaket";
    }
}

// ===== Versandarten.java =====
package de.csbrad.shop;

import java.util.Map;

/** Ordnet die Kennungen aus dem Shop-Formular den Versandarten zu; einzige Stelle, die neue Versandarten kennt. */
public class Versandarten {

    private final Map<String, Versandart> nachKennung = Map.of(
            "PAKET", new Paketversand(),
            "SPEDITION", new Speditionsversand(),
            "ABHOLUNG", new Abholung(),
            "KURIER", new Kurierzustellung(),
            "EXPRESS", new Expressversand());

    public Versandart zuKennung(String kennung) {
        Versandart versandart = nachKennung.get(kennung);
        if (versandart == null) {
            throw new IllegalArgumentException("Unbekannte Versandart: " + kennung);
        }
        return versandart;
    }
}

// ===== 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 Versandart versandart;
    private final String gutscheincode;
    private final String kundenEmail;
    private String bestellnummer;

    public Bestellung(Bestellanfrage anfrage, Versandart versandart) {
        pruefePositionen(anfrage.positionen());
        this.positionen = List.copyOf(anfrage.positionen());
        this.versandart = versandart;
        this.gutscheincode = anfrage.gutscheincode();
        this.kundenEmail = anfrage.kundenEmail();
        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 versandart.kosten(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 Versandart 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;
    }
}

// ===== 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" + bestellung.versandart().mailzeile());
    }

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

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

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

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

    private final Versandarten versandarten = new Versandarten();
    private final Mailversand mailversand = new Mailversand();
    private final BestellRepository repository = new BestellRepository();
    private final BestellungSpeichern speicher = repository;

    public Bestellung abschliessen(Bestellanfrage anfrage) {
        Versandart versandart = versandarten.zuKennung(anfrage.versandart());
        Bestellung bestellung = new Bestellung(anfrage, versandart);
        versandart.pruefe(bestellung);
        speicher.speichere(bestellung);
        mailversand.sendeBestaetigung(bestellung);
        return bestellung;
    }

    public Mailversand mailversand() {
        return mailversand;
    }

    public BestellungLesen repository() {
        return repository;
    }
}

Alle 22 Tests sind grün (19 aus Stand 07 und die drei neuen). Für das Expresspaket ist außer der neuen Klasse nur ein Eintrag in Versandarten nötig. Diese Lösung entspricht Stand 08 der Kata. Die Klassen BestellungSpeichern und BestellungLesen aus Stand 08 gehören zu Teilaufgabe e.

Bewertung: Interface mit drei Operationen (2 Punkte), fünf Versandarten mit unveränderten Regeln, Kurier und Express nutzen die Paketregeln per Komposition statt Vererbung (4 Punkte), Zuordnung an einer Stelle mit Ablehnung unbekannter Kennungen (2 Punkte), Bestellung und Mailversand ohne Fallunterscheidung (2 Punkte), alle Tests grün (2 Punkte).

d) Zeichne das UML-Klassendiagramm des Versandteils nach dem Umbau mit dem Interface, allen Versandarten, der Zuordnung und Bestellung. Operationen der Versandarten, die nur das Interface realisieren, musst Du nicht wiederholen. (4 Punkte)

UML-Klassendiagramm von Stand 08 der Kata: das Interface Versandart mit pruefe, kosten und mailzeile wird von Paketversand, Speditionsversand, Abholung, Kurierzustellung und Expressversand realisiert; Kurierzustellung und Expressversand enthalten je einen Paketversand namens paketregeln; Versandarten ordnet Kennungen den Versandarten zu; Bestellung kennt genau eine Versandart (mit KI erstellt)

Bewertung: Interface mit Stereotyp «interface» und drei Operationen (1 Punkt), fünf Realisierungen mit gestrichelter Linie und hohlem Dreieck (1 Punkt), Kompositionen zu Paketversand (1 Punkt), Zuordnung und Assoziation von Bestellung zu Versandart mit Multiplizitäten (1 Punkt). Der Bestellabschluss darf fehlen.

e) Der Bestellabschluss nutzt vom BestellRepository nur speichere, eine geplante Bestellübersicht im Backoffice nur findeNachNummer und alle. Führe nach dem Interface Segregation Principle passende Schnittstellen ein, lass den Bestellabschluss nur noch über seine Schnittstelle speichern und begründe den Zuschnitt. (4 Punkte)

// Kata CSB-Rad, Stand 08: schlanke Interfaces (Bestellabschluss siehe Teilaufgabe c)
// ===== BestellungSpeichern.java =====
package de.csbrad.shop;

/** Schlankes Interface für den Bestellabschluss: nur speichern (ISP). */
public interface BestellungSpeichern {

    /** Speichert die Bestellung und vergibt dabei die Bestellnummer. */
    void speichere(Bestellung bestellung);
}

// ===== BestellungLesen.java =====
package de.csbrad.shop;

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

/** Schlankes Interface für Auswertungen, z. B. die Bestellübersicht im Backoffice: nur lesen (ISP). */
public interface BestellungLesen {

    Optional<Bestellung> findeNachNummer(String bestellnummer);

    List<Bestellung> alle();
}

// ===== 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 implements BestellungSpeichern, BestellungLesen {

    private static final int ERSTE_BESTELLNUMMER = 1001;

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

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

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

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

Im Bestellabschluss speichert ein Attribut vom Typ BestellungSpeichern (siehe Lösung zu c); die Methode repository() liefert für die Tests nur noch BestellungLesen.

Begründung: Das ISP schneidet Schnittstellen aus Sicht der Nutzenden. Der Bestellabschluss braucht nur die Rolle „speichern“, Auswertungen nur die Rolle „lesen“. So hängt keiner von Methoden ab, die er nicht nutzt; ein Test-Double für den Bestellabschluss braucht später nur speichere (in Java sogar als Lambda), und eine Auswertung kann nicht versehentlich speichern. Das Repository bleibt eine zusammenhängende Klasse, die beide Rollen erfüllt.

Bewertung: zwei Interfaces mit passendem Zuschnitt (2 Punkte), Repository realisiert beide und Bestellabschluss nutzt BestellungSpeichern (1 Punkt), Begründung aus Sicht der Nutzenden (1 Punkt).

Aufgabe 3.2: Ein Interface für alle im Mitfahrlabor aufteilen (Profilkompetenz)

(ca. 21 min Bearbeitungszeit)

Im Mitfahrlabor greifen drei Programmteile auf die Fahrten zu: Der Flurmonitor zeigt offene Fahrten auf den Monitoren der Schule an, und zwar anonym. Der Erinnerungsdienst schreibt den bestätigten Mitfahrenden vor der Fahrt eine Mail. Im Fahrerbereich bieten Fahrer*innen Fahrten an, nehmen Mitfahrende mit und sagen Fahrten ab. Alle drei hängen vom Interface Fahrtenportal ab:

UML-Klassendiagramm der Ausgangslage im Mitfahrlabor: das Interface Fahrtenportal mit bieteAn, storniere, bestaetige, offeneFahrten, freiePlaetze und bestaetigte wird von FahrtenSpeicher realisiert; Flurmonitor, Erinnerungsdienst und Fahrerbereich kennen jeweils genau ein Fahrtenportal namens portal (mit KI erstellt)

// Paket de.mitfahrlabor, Stand vor dem Umbau
// ===== Fahrt.java =====
package de.mitfahrlabor;

/** Ein Fahrtangebot ohne personenbezogene Daten. */
public record Fahrt(int id, String haltepunkt, String abfahrt, int plaetze) {
}

// ===== Fahrtenportal.java =====
package de.mitfahrlabor;

import java.util.List;

/** Alles, was die App mit Fahrten macht. */
public interface Fahrtenportal {

    int bieteAn(String fahrerEmail, String haltepunkt, String abfahrt, int plaetze);

    void storniere(int fahrtId);

    void bestaetige(int fahrtId, String mitfahrerEmail);

    /** Nicht stornierte Fahrten mit mindestens einem freien Platz. */
    List<Fahrt> offeneFahrten();

    int freiePlaetze(int fahrtId);

    List<String> bestaetigte(int fahrtId);
}

// ===== FahrtenSpeicher.java =====
package de.mitfahrlabor;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/** Hält die Fahrten im Arbeitsspeicher. */
public class FahrtenSpeicher implements Fahrtenportal {

    private final Map<Integer, Fahrt> fahrten = new HashMap<>();
    private final Map<Integer, String> fahrerEmails = new HashMap<>();
    private final Map<Integer, List<String>> bestaetigte = new HashMap<>();
    private int naechsteId = 1;

    @Override
    public int bieteAn(String fahrerEmail, String haltepunkt, String abfahrt, int plaetze) {
        int id = naechsteId++;
        fahrten.put(id, new Fahrt(id, haltepunkt, abfahrt, plaetze));
        fahrerEmails.put(id, fahrerEmail);
        bestaetigte.put(id, new ArrayList<>());
        return id;
    }

    @Override
    public void storniere(int fahrtId) {
        fahrten.remove(fahrtId);
    }

    @Override
    public void bestaetige(int fahrtId, String mitfahrerEmail) {
        if (freiePlaetze(fahrtId) == 0) {
            throw new IllegalStateException("Keine Plätze mehr frei");
        }
        bestaetigte.get(fahrtId).add(mitfahrerEmail);
    }

    @Override
    public List<Fahrt> offeneFahrten() {
        return fahrten.values().stream()
                .filter(f -> freiePlaetze(f.id()) > 0)
                .sorted((a, b) -> a.abfahrt().compareTo(b.abfahrt()))
                .toList();
    }

    @Override
    public int freiePlaetze(int fahrtId) {
        return fahrten.get(fahrtId).plaetze() - bestaetigte.get(fahrtId).size();
    }

    @Override
    public List<String> bestaetigte(int fahrtId) {
        return List.copyOf(bestaetigte.get(fahrtId));
    }
}

// ===== Flurmonitor.java =====
package de.mitfahrlabor;

import java.util.List;

/** Zeigt offene Fahrten anonym auf den Flurmonitoren der Schule. */
public class Flurmonitor {

    private final Fahrtenportal portal;

    public Flurmonitor(Fahrtenportal portal) {
        this.portal = portal;
    }

    public List<String> anzeigezeilen() {
        return portal.offeneFahrten().stream()
                .map(f -> f.abfahrt() + " ab " + f.haltepunkt() + ": " + portal.freiePlaetze(f.id()) + " Plätze frei")
                .toList();
    }
}

// ===== Erinnerungsdienst.java =====
package de.mitfahrlabor;

import java.util.List;

/** Erinnert die bestätigten Mitfahrenden an eine Fahrt. */
public class Erinnerungsdienst {

    private final Fahrtenportal portal;

    public Erinnerungsdienst(Fahrtenportal portal) {
        this.portal = portal;
    }

    public List<String> erinnerungen(int fahrtId, String text) {
        return portal.bestaetigte(fahrtId).stream().map(mail -> "Mail an " + mail + ": " + text).toList();
    }
}

// ===== Fahrerbereich.java =====
package de.mitfahrlabor;

/** Bereich der App, in dem Fahrer*innen Fahrten anbieten, Anfragen bestätigen und stornieren. */
public class Fahrerbereich {

    private final Fahrtenportal portal;

    public Fahrerbereich(Fahrtenportal portal) {
        this.portal = portal;
    }

    public int neueFahrt(String fahrerEmail, String haltepunkt, String abfahrt, int plaetze) {
        return portal.bieteAn(fahrerEmail, haltepunkt, abfahrt, plaetze);
    }

    public void nimmMit(int fahrtId, String mitfahrerEmail) {
        portal.bestaetige(fahrtId, mitfahrerEmail);
    }

    public void sageAb(int fahrtId) {
        portal.storniere(fahrtId);
    }
}

Die Tests sind grün; für den Flurmonitor gibt es einen handgeschriebenen Stub:

// ===== FlurmonitorTest.java =====
package de.mitfahrlabor;

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

import java.util.List;
import org.junit.jupiter.api.Test;

class FlurmonitorTest {

    /** Stub: liefert feste Fahrten; alles andere braucht der Flurmonitor nicht. */
    private static class PortalStub implements Fahrtenportal {

        @Override
        public List<Fahrt> offeneFahrten() {
            return List.of(new Fahrt(1, "Gütersloh Hauptbahnhof", "07:15", 3));
        }

        @Override
        public int freiePlaetze(int fahrtId) {
            return 2;
        }

        @Override
        public int bieteAn(String fahrerEmail, String haltepunkt, String abfahrt, int plaetze) {
            throw new UnsupportedOperationException();
        }

        @Override
        public void storniere(int fahrtId) {
            throw new UnsupportedOperationException();
        }

        @Override
        public void bestaetige(int fahrtId, String mitfahrerEmail) {
            throw new UnsupportedOperationException();
        }

        @Override
        public List<String> bestaetigte(int fahrtId) {
            throw new UnsupportedOperationException();
        }
    }

    @Test
    void zeigtOffeneFahrtenAnonym() {
        assertEquals(List.of("07:15 ab Gütersloh Hauptbahnhof: 2 Plätze frei"), new Flurmonitor(new PortalStub()).anzeigezeilen());
    }
}

// ===== FahrtenSpeicherTest.java =====
package de.mitfahrlabor;

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

import java.util.List;
import org.junit.jupiter.api.Test;

class FahrtenSpeicherTest {

    private final FahrtenSpeicher speicher = new FahrtenSpeicher();
    private final Fahrerbereich fahrer = new Fahrerbereich(speicher);

    @Test
    void vollbesetzteUndStornierteFahrtenSindNichtOffen() {
        int frueh = fahrer.neueFahrt("ben.richter@dbh-bielefeld.example", "Gütersloh Hauptbahnhof", "07:15", 1);
        int spaet = fahrer.neueFahrt("niklas.fischer@dbh-bielefeld.example", "Bad Salzuflen Busbahnhof", "07:30", 3);
        int dritte = fahrer.neueFahrt("ben.richter@dbh-bielefeld.example", "Gütersloh Hauptbahnhof", "07:05", 2);
        fahrer.nimmMit(frueh, "aylin.kaya@dbh-bielefeld.example");
        fahrer.sageAb(dritte);
        assertEquals(List.of("07:30 ab Bad Salzuflen Busbahnhof: 3 Plätze frei"), new Flurmonitor(speicher).anzeigezeilen());
        assertEquals(0, speicher.freiePlaetze(frueh));
        assertEquals(3, speicher.freiePlaetze(spaet));
        assertThrows(IllegalStateException.class, () -> fahrer.nimmMit(frueh, "lukas.hofmann@dbh-bielefeld.example"));
    }

    @Test
    void erinnertNurBestaetigte() {
        int fahrt = fahrer.neueFahrt("ben.richter@dbh-bielefeld.example", "Gütersloh Hauptbahnhof", "07:15", 3);
        fahrer.nimmMit(fahrt, "aylin.kaya@dbh-bielefeld.example");
        assertEquals(List.of("Mail an aylin.kaya@dbh-bielefeld.example: Morgen 07:15 ab Gütersloh Hauptbahnhof"),
                new Erinnerungsdienst(speicher).erinnerungen(fahrt, "Morgen 07:15 ab Gütersloh Hauptbahnhof"));
    }
}

a) Ermittle in einer Tabelle, welche Operationen des Fahrtenportal der Flurmonitor, der Erinnerungsdienst und der Fahrerbereich jeweils nutzen. (4 Punkte)

Nutzung der Operationen von Fahrtenportal (Beispiellösung)
Operation Flurmonitor Erinnerungsdienst Fahrerbereich
bieteAn X
storniere X
bestaetige X
offeneFahrten X
freiePlaetze X
bestaetigte X

Keine Operation wird von zwei Programmteilen genutzt; jeder Nutzende braucht nur einen kleinen Teil des Interfaces.

Bewertung: je richtig ausgefüllte Spalte 1 Punkt, 1 Punkt für die Folgerung, dass sich die Nutzung nicht überschneidet.

b) Erläutere zwei Nachteile des breiten Interfaces, einen davon am Beispiel des Stubs im Test für den Flurmonitor, den anderen am Beispiel der anonymen Anzeige. (4 Punkte)

Test-Double: Der Stub PortalStub muss alle sechs Operationen realisieren, obwohl der Flurmonitor nur zwei aufruft. Vier Methoden werfen nur Ausnahmen; das bläht den Test auf, verschleiert, welche Methoden wirklich gebraucht werden, und ist im Grunde ein LSP-Verstoß. Kommt eine Operation zu Fahrtenportal dazu, muss auch dieser Stub geändert werden, obwohl der Flurmonitor nichts damit zu tun hat (2 Punkte).

Anonyme Anzeige: Über das Fahrtenportal kann der Flurmonitor auch bestaetigte aufrufen und damit die Mailadressen der Mitfahrenden lesen oder sogar Fahrten stornieren. Dass er das nicht tut, steht nur im Kommentar; der Typ verhindert es nicht. Nach dem Grundsatz der Datenminimierung soll der Teil, der auf öffentlichen Monitoren anzeigt, gar keinen Zugriff auf personenbezogene Daten haben (2 Punkte). Gleichwertig: unnötige Kopplung, weil Änderungen an bieteAn alle drei Nutzenden betreffen.

c) Baue den Code nach dem Interface Segregation Principle um: ein Interface je Rolle, FahrtenSpeicher erfüllt alle Rollen, jeder Programmteil hängt nur von seiner Rolle ab. In Python sollen die Rollen als Protocol formuliert werden, sodass der Stub nicht mehr erben muss. Passe den Stub im Test an, alle Tests müssen grün bleiben. (8 Punkte)

// Paket de.mitfahrlabor nach dem Umbau (Fahrt unverändert)
// ===== FahrtenAnzeigen.java =====
package de.mitfahrlabor;

import java.util.List;

/** Rolle Flurmonitor: nur lesen, nur anonyme Daten. */
public interface FahrtenAnzeigen {

    /** Nicht stornierte Fahrten mit mindestens einem freien Platz. */
    List<Fahrt> offeneFahrten();

    int freiePlaetze(int fahrtId);
}

// ===== MitfahrendeLesen.java =====
package de.mitfahrlabor;

import java.util.List;

/** Rolle Erinnerungsdienst: wer ist für eine Fahrt bestätigt? */
public interface MitfahrendeLesen {

    List<String> bestaetigte(int fahrtId);
}

// ===== FahrtenVerwalten.java =====
package de.mitfahrlabor;

/** Rolle Fahrerbereich: Fahrten anbieten, Mitfahrende bestätigen, stornieren. */
public interface FahrtenVerwalten {

    int bieteAn(String fahrerEmail, String haltepunkt, String abfahrt, int plaetze);

    void storniere(int fahrtId);

    void bestaetige(int fahrtId, String mitfahrerEmail);
}

// ===== Flurmonitor.java =====
package de.mitfahrlabor;

import java.util.List;

/** Zeigt offene Fahrten anonym auf den Flurmonitoren der Schule. */
public class Flurmonitor {

    private final FahrtenAnzeigen fahrten;

    public Flurmonitor(FahrtenAnzeigen fahrten) {
        this.fahrten = fahrten;
    }

    public List<String> anzeigezeilen() {
        return fahrten.offeneFahrten().stream()
                .map(f -> f.abfahrt() + " ab " + f.haltepunkt() + ": " + fahrten.freiePlaetze(f.id()) + " Plätze frei")
                .toList();
    }
}

// ===== Erinnerungsdienst.java =====
package de.mitfahrlabor;

import java.util.List;

/** Erinnert die bestätigten Mitfahrenden an eine Fahrt. */
public class Erinnerungsdienst {

    private final MitfahrendeLesen mitfahrende;

    public Erinnerungsdienst(MitfahrendeLesen mitfahrende) {
        this.mitfahrende = mitfahrende;
    }

    public List<String> erinnerungen(int fahrtId, String text) {
        return mitfahrende.bestaetigte(fahrtId).stream().map(mail -> "Mail an " + mail + ": " + text).toList();
    }
}

// ===== Fahrerbereich.java =====
package de.mitfahrlabor;

/** Bereich der App, in dem Fahrer*innen Fahrten anbieten, Anfragen bestätigen und stornieren. */
public class Fahrerbereich {

    private final FahrtenVerwalten verwaltung;

    public Fahrerbereich(FahrtenVerwalten verwaltung) {
        this.verwaltung = verwaltung;
    }

    public int neueFahrt(String fahrerEmail, String haltepunkt, String abfahrt, int plaetze) {
        return verwaltung.bieteAn(fahrerEmail, haltepunkt, abfahrt, plaetze);
    }

    public void nimmMit(int fahrtId, String mitfahrerEmail) {
        verwaltung.bestaetige(fahrtId, mitfahrerEmail);
    }

    public void sageAb(int fahrtId) {
        verwaltung.storniere(fahrtId);
    }
}

In Java ändert sich beim FahrtenSpeicher nur die Kopfzeile: public class FahrtenSpeicher implements FahrtenVerwalten, FahrtenAnzeigen, MitfahrendeLesen; alle Methoden und @Override bleiben gültig. In Python erbt FahrtenSpeicher nichts mehr; er erfüllt die drei Protocols strukturell, was mypy prüft. Der neue Test:

// ===== FlurmonitorTest.java =====
package de.mitfahrlabor;

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

import java.util.List;
import org.junit.jupiter.api.Test;

class FlurmonitorTest {

    /** Stub: liefert feste Fahrten; mehr kann der Flurmonitor gar nicht aufrufen. */
    private static class AnzeigeStub implements FahrtenAnzeigen {

        @Override
        public List<Fahrt> offeneFahrten() {
            return List.of(new Fahrt(1, "Gütersloh Hauptbahnhof", "07:15", 3));
        }

        @Override
        public int freiePlaetze(int fahrtId) {
            return 2;
        }
    }

    @Test
    void zeigtOffeneFahrtenAnonym() {
        assertEquals(List.of("07:15 ab Gütersloh Hauptbahnhof: 2 Plätze frei"),
                new Flurmonitor(new AnzeigeStub()).anzeigezeilen());
    }

    @Test
    void erinnerungsdienstMitLambdaStub() {
        // MitfahrendeLesen hat nur eine Methode und lässt sich deshalb als Lambda schreiben
        MitfahrendeLesen stub = fahrtId -> List.of("aylin.kaya@dbh-bielefeld.example");
        assertEquals(List.of("Mail an aylin.kaya@dbh-bielefeld.example: Morgen 07:15"),
                new Erinnerungsdienst(stub).erinnerungen(1, "Morgen 07:15"));
    }
}

Der Stub hat nur noch die zwei Methoden, die der Flurmonitor braucht. MitfahrendeLesen hat in Java genau eine Methode und lässt sich deshalb als Lambda ersetzen. Alle Tests sind grün.

Bewertung: drei Interfaces bzw. Protocols mit passendem Zuschnitt (3 Punkte), FahrtenSpeicher erfüllt alle drei, in Python ohne Vererbung (1 Punkt), die drei Programmteile hängen nur von ihrer Rolle ab (2 Punkte), schlanker Stub und grüne Tests (2 Punkte).

d) Zeichne das UML-Klassendiagramm nach dem Umbau mit den Interfaces, ihren Operationen und allen Beziehungen. (4 Punkte)

UML-Klassendiagramm nach dem Umbau im Mitfahrlabor: die Interfaces FahrtenAnzeigen mit offeneFahrten und freiePlaetze, MitfahrendeLesen mit bestaetigte und FahrtenVerwalten mit bieteAn, storniere und bestaetige werden alle von FahrtenSpeicher realisiert; Flurmonitor kennt genau ein FahrtenAnzeigen, Erinnerungsdienst genau ein MitfahrendeLesen und Fahrerbereich genau ein FahrtenVerwalten; eine Notiz sagt, dass eine Klasse alle drei Rollen erfüllt (mit KI erstellt)

Bewertung: drei Interfaces mit Stereotyp und richtigen Operationen (2 Punkte), drei Realisierungen durch FahrtenSpeicher (1 Punkt), gerichtete Assoziationen der Nutzenden zu ihrem Interface mit Multiplizität (1 Punkt).

e) Ein Teammitglied schlägt vor, konsequent für jede Operation ein eigenes Interface anzulegen, „dann ist das ISP perfekt erfüllt“. Ein anderes möchte Fahrtenportal zusätzlich behalten, als Interface, das von allen drei Rollen-Interfaces erbt. Beurteile beide Vorschläge. (3 Punkte)

Ein Interface je Operation zerlegt zusammengehörige Rollen: Der Flurmonitor braucht offeneFahrten und freiePlaetze immer gemeinsam, der Fahrerbereich alle drei Verwaltungsoperationen. Er bekäme dann drei Abhängigkeiten statt einer, und die Namen der vielen Interfaces sagen nichts mehr über die Rolle aus. Maßstab des ISP ist die Rolle aus Sicht der Nutzenden, nicht die einzelne Methode (1,5 Punkte).

Ein zusätzliches Sammel-Interface Fahrtenportal extends FahrtenVerwalten, FahrtenAnzeigen, MitfahrendeLesen (Python: Protocol, das von den drei Protocols erbt) schadet nicht, solange es nur dort verwendet wird, wo wirklich alles gebraucht wird, etwa beim Zusammenbauen der Anwendung oder in Integrationstests. Es darf aber nicht wieder als Parametertyp der einzelnen Programmteile auftauchen, sonst ist die Aufteilung wirkungslos. Solange niemand es braucht, spricht YAGNI dagegen (1,5 Punkte).


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 Erweitern statt Ändern: Open Closed Principle und Interface Segregation Principle” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/erweitern-statt-aendern-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: