CodeGym /Kurse /Spring Core /Interfaces und austauschbare Implementierungen

Interfaces und austauschbare Implementierungen

Spring Core
Level 2, Lektion 3
Verfügbar

1. Ein Interface als „Rolle", nicht als Zwischenschicht

Ein Interface ist nicht als obligatorische Zwischenschicht nützlich, sondern als Beschreibung einer Rolle. Wenn ein Entwickler zum ersten Mal vom DI-Ansatz hört, entsteht oft ein ganz natürlicher Gedanke: „Aha! Also muss ich jetzt für alles ein Interface machen!" Das ist wie einen Akkuschrauber kaufen und damit anfangen, Suppe umzurühren. Interfaces helfen wirklich mächtig – aber nur wenn man sie als Rollenbeschreibung verwendet und nicht als „magische Formalität aus dem Lehrbuch".

Die nützlichste Art, über ein Interface nachzudenken ist: Ein Interface ist ein Verhaltensversprechen, das ein Service braucht, und die Implementierung ist die konkrete Art, dieses Versprechen einzulösen. Das heißt, dem Service ist es normalerweise egal, wie genau eine Benachrichtigung gesendet wurde – in die Konsole, per E-Mail, per SMS oder per Brieftaube –, ihm ist wichtig, dass eine Benachrichtigung gesendet werden kann. Sobald du anfängst, Abhängigkeiten genau so zu formulieren, wird DI-Code flexibel: Details ändern sich, das Szenario bleibt bestehen.

Schauen wir uns ein minimales Beispiel an. Erst – die „harte Bindung" an eine Klasse:

public class ConsoleNotificationSender {
    public void send(String text) {
        // Infrastrukturdetail: konkret der "Konsolen"-Sendekanal
        System.out.println("[NOTIFY] " + text); // [NOTIFY] Order created: ORD-1
    }
}

public class OrderPlacementService {
    // Schlechte Kopplung: Der Service hängt von einer konkreten Klasse ab, nicht von einer Rolle
    private final ConsoleNotificationSender sender;

    public OrderPlacementService(ConsoleNotificationSender sender) {
        // DI ist zwar vorhanden (die Abhängigkeit kommt von außen), aber die Rollenabstraktion fehlt
        this.sender = sender;
    }
}

DI ist zwar vorhanden – die Abhängigkeit kommt von außen –, aber die Rolle ist immer noch nicht von der Implementierung getrennt. Jetzt etwas sauberer: Abhängigkeit von einer Rolle.

public interface NotificationSender {
    // Rollenvertrag: "kann eine Benachrichtigung senden"
    void send(String text);
}

public class OrderPlacementService {
    // Gute Kopplung: Der Service hängt von der Rolle ab, nicht von einem konkreten Detail
    private final NotificationSender sender;

    public OrderPlacementService(NotificationSender sender) {
        // Die konkrete Implementierung wird von außen gewählt (im Composition Root)
        this.sender = sender;
    }
}

Der Service „kennt" die konkrete Klasse nicht, er kennt nur den Vertrag. Und das ist keine Philosophie um der Philosophie willen, sondern der praktische Schlüssel zur Austauschbarkeit.

Abstraktionen in ContextFlow: Wo sie gebraucht werden

Eine der wichtigsten Fähigkeiten – und für einen Junior ist das fast eine Superkraft – ist es, einen „Variabilitätspunkt" von einer „einfachen Klasse" zu unterscheiden. Abstraktion ist dort berechtigt, wo man wirklich verschiedene Verhaltensweisen zulässt, oder dort, wo man Infrastruktur von Business-Logik isolieren möchte. In ContextFlow gibt es solche Stellen recht viele – und das ist kein Zufall: Das Projekt wurde bewusst so gewählt, dass diese Punkte natürlich sind.

Lassen wir uns eine einfache technische Logik formulieren. Wenn man sich ehrlich vorstellen kann, dass man morgen eine andere Verhaltensweise braucht, ist ein Interface ein guter Kandidat. Wenn es keine anderen Varianten gibt und die Klasse einfach Daten oder interne Logik ohne Außenwirkung repräsentiert, ist ein Interface meistens Rauschen.

Unten eine kleine „Rollenkarte" unserer Anwendung. Das ist keine Liste „obligatorischer Interfaces nach Vorschrift", sondern ein Hinweis, wo Abstraktion wirklich hilft:

Rolle (was der Service braucht) Interface Beispielimplementierungen Warum es sich ändern kann
Bestellungen speichern OrderStore InMemoryOrderStore, später könnte es FileOrderStore geben Umgebung, Tests, Speicherart
Business-Audit schreiben AuditWriter ConsoleAuditWriter, NoOpAuditWriter In Tests „schweigen", im Demo in Datei/Konsole schreiben
Benachrichtigungen senden NotificationSender ConsoleNotificationSender, EmailNotificationSender Kanäle unterscheiden sich, aber die Rolle ist eine
Rabatt berechnen DiscountPolicy NoDiscountPolicy, LoyalCustomerDiscountPolicy Business-Regeln ändern sich am häufigsten
ID generieren OrderIdGenerator UuidOrderIdGenerator, DeterministicOrderIdGenerator Tests brauchen Vorhersagbarkeit

Schlüsselpunkt: All diese Rollen sind Abhängigkeiten, ohne die das Szenario entweder unmöglich ist oder schnell zu einem „Mähdrescher mit 400 Zeilen if-else" wird. Wir versuchen nicht, „überall Interfaces einzuführen", sondern fügen sie dort hinzu, wo es echte Gründe gibt: Variabilität, Testbarkeit, Isolation.

2. Ports von ContextFlow: OrderStore und andere

Jetzt machen wir einen wichtigen Schritt: Wir formulieren diese „Rollen" als separate Interfaces und geben ihnen erste einfache Implementierungen. Das ähnelt der Situation, wenn man aufhört zu sagen „Bring mir genau diese konkrete Tasse" und anfängt zu sagen „Bring mir eine Tasse" – und welche genau es ist, entscheidet derjenige, der den Tisch deckt, also der Composition Root. Ja, das klingt etwas alltäglich. Aber gutes DI ist insgesamt über alltägliche Vernünftigkeit.

Beginnen wir mit Benachrichtigungen. Ein kleines Interface ist gut. Damit die Grenze mit den Augen lesbar ist, trenne ich den Code ein wenig in Pakete: Rollen separat, Infrastrukturimplementierungen separat. Das ist immer noch dasselbe ContextFlow; hier ist es wichtiger, die Grenze zwischen Vertrag und Implementierung zu sehen, als den gesamten Code in einem Paket zu halten.

package com.example.contextflow.domain.ports;

public interface NotificationSender {
    // Domänenport: Dem Business-Code ist wichtig "kann senden", nicht "wohin genau"
    void send(String text);
}

Implementierung für die Konsole – die „lernfreudigste" und ehrlichste:

package com.example.contextflow.infrastructure.notification;

import com.example.contextflow.domain.ports.NotificationSender;

public class ConsoleNotificationSender implements NotificationSender {
    public void send(String text) {
        // Infrastruktur: Benachrichtigung in stdout ausgeben
        System.out.println("[NOTIFY] " + text); // [NOTIFY] Order created: ORD-1
    }
}

Jetzt Audit. Wieder dieselbe Idee: Die Rolle ist eine – „ein Business-Ereignis aufzeichnen" – und wohin genau, ist dem Service egal.

package com.example.contextflow.domain.ports;

public interface AuditWriter {
    // Domänenport: Business-Ereignis festhalten (Speicherung/Ausgabe ist ein Detail)
    void write(String message);
}

Konsolenversion:

package com.example.contextflow.infrastructure.audit;

import com.example.contextflow.domain.ports.AuditWriter;

public class ConsoleAuditWriter implements AuditWriter {
    public void write(String message) {
        // Infrastruktur: Audit in die Konsole schreiben
        System.out.println("[AUDIT] " + message); // [AUDIT] created ORD-1
    }
}

Und die „stille" Version – sehr nützlich für Tests und Szenarien ohne unnötiges Rauschen:

package com.example.contextflow.infrastructure.audit;

import com.example.contextflow.domain.ports.AuditWriter;

public class NoOpAuditWriter implements AuditWriter {
    public void write(String message) {
        // Stub: Wir machen bewusst nichts (praktisch für Tests/stille Szenarien)
    }
}

Bestellungsspeicher – ebenfalls eine Rolle. Für den Anfang reicht uns die In-Memory-Variante:

package com.example.contextflow.domain.ports;

import com.example.contextflow.domain.model.Order;
import java.util.Optional;

public interface OrderStore {
    // Bestellung speichern (keine Details hier: DB/Datei/Speicher – egal)
    void save(Order order);

    // Optional zurückgeben, damit das Fehlen einer Bestellung ein expliziter Fall ist
    Optional<Order> findById(String orderId);
}

Einfachste Implementierung mit Map:

package com.example.contextflow.infrastructure.store;

import com.example.contextflow.domain.model.Order;
import com.example.contextflow.domain.ports.OrderStore;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class InMemoryOrderStore implements OrderStore {
    // Im Lernmodus alles im Prozessprozessor-Speicher halten
    private final Map<String, Order> orders = new HashMap<>();

    public void save(Order order) {
        // Schlüssel ist die Bestell-ID, Wert ist die Bestellung selbst
        orders.put(order.getId(), order);
    }

    public Optional<Order> findById(String orderId) {
        // Ergebnis in Optional einwickeln: null „leckt" nach außen nicht
        return Optional.ofNullable(orders.get(orderId));
    }
}

Rabatt. Hier ist es wichtig, sich nicht mitreißen zu lassen und keine „Rabatt-Engine" zu bauen – das ist ein eigener Beruf und ein eigener Schmerz. Wir brauchen eine Rolle und zwei Implementierungen: „kein Rabatt" und „Rabatt für Treue".

package com.example.contextflow.domain.ports;

import com.example.contextflow.domain.model.Customer;

public interface DiscountPolicy {
    // Rabattbetrag zurückgeben (in bedingten „Geldeinheiten"), nicht den Endpreis
    int discountFor(Customer customer, int total);
}

Ohne Rabatt:

package com.example.contextflow.application.discount;

import com.example.contextflow.domain.model.Customer;
import com.example.contextflow.domain.ports.DiscountPolicy;

public class NoDiscountPolicy implements DiscountPolicy {
    public int discountFor(Customer customer, int total) {
        // Standardrichtlinie: kein Rabatt
        return 0;
    }
}

Rabatt für Treue:

package com.example.contextflow.application.discount;

import com.example.contextflow.domain.model.Customer;
import com.example.contextflow.domain.ports.DiscountPolicy;

public class LoyalCustomerDiscountPolicy implements DiscountPolicy {
    public int discountFor(Customer customer, int total) {
        // Beispiel-Business-Regel: 10% vom Betrag für treue Kunden
        return customer.isLoyal() ? (total / 10) : 0; // 10%
    }
}

Und der ID-Generator. Das ist überhaupt das klassische Beispiel dafür, warum ein Interface nützlich ist: Im „Echtbetrieb" möchte man UUID, in Tests – Deterministik.

package com.example.contextflow.domain.ports;

public interface OrderIdGenerator {
    // Rolle: nächste ID liefern (Das Detail der Generierung ist nicht die Sorge des Services)
    String nextId();
}

Deterministische Variante:

package com.example.contextflow.infrastructure.id;

import com.example.contextflow.domain.ports.OrderIdGenerator;

public class DeterministicOrderIdGenerator implements OrderIdGenerator {
    // Zähler für vorhersagbare IDs – besonders praktisch in Tests
    private int seq = 1;

    public String nextId() {
        // Vorhersagbare Sequenz: ORD-1, ORD-2, ...
        return "ORD-" + (seq++);
    }
}

An dieser Stelle kann man bemerken, dass es mehr Interfaces geworden ist. Das ist normal. Hauptsache – jedes von ihnen beantwortet die Frage „Welche Rolle erwartet der Service zu erhalten?" und existiert nicht „weil es so sein muss".

3. Verhalten über den Composition Root umschalten

Interfaces sind wunderbar genau bis zu dem Moment, wenn man sie wirklich verwendet. Das heißt – bis man eine Implementierung durch eine andere ersetzt und sieht, dass der Business-Code sich kein bisschen verändert hat. Genau dafür führen wir überhaupt Abstraktionen ein: damit Änderungen dort stattfinden, wo ihr Platz ist – im Zusammenbaupunkt, nicht innerhalb der Szenarien.

Wir erstellen einen vereinfachten OrderPlacementService, der von zwei Rollen abhängt: Benachrichtigung und Audit. Beachte: Der Service „spricht" mit Interfaces, nicht mit konkreten Klassen.

package com.example.contextflow.application.service;

import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.domain.ports.NotificationSender;

public class OrderPlacementService {
    // Rollen, die das Szenario braucht: benachrichtigen und Audit aufzeichnen
    private final NotificationSender sender;
    private final AuditWriter auditWriter;

    public OrderPlacementService(NotificationSender sender, AuditWriter auditWriter) {
        // Konkrete Implementierungen kommen von außen
        this.sender = sender;
        this.auditWriter = auditWriter;
    }

    public void place(String orderId) {
        // Das Business-Szenario weiß nicht, WIE gesendet und WOHIN Audit geschrieben wird – es ruft einfach Rollen
        sender.send("Order created: " + orderId);
        auditWriter.write("created " + orderId);
    }
}

Jetzt die Komposition „für den normalen Start":

package com.example.contextflow;

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.infrastructure.audit.ConsoleAuditWriter;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;

public class Main {
    public static void main(String[] args) {
        // Composition Root: Hier wählen wir konkrete Implementierungen der Rollen
        OrderPlacementService service = new OrderPlacementService(
                new ConsoleNotificationSender(),
                new ConsoleAuditWriter()
        );

        // Szenario starten
        service.place("ORD-1");
        // [NOTIFY] Order created: ORD-1
        // [AUDIT] created ORD-1
    }
}

Und jetzt das Leckerste: Wir ändern das Verhalten, ohne den Service zu ändern. Zum Beispiel möchten wir den „stillen Modus" – bedingt ein Testszenario, in dem Audit ausgeschaltet ist.

package com.example.contextflow;

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.infrastructure.audit.NoOpAuditWriter;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;

public class Main {
    public static void main(String[] args) {
        // Nur das Wiring ändern: Jetzt „schweigt" das Audit
        OrderPlacementService service = new OrderPlacementService(
                new ConsoleNotificationSender(),
                new NoOpAuditWriter()
        );

        service.place("ORD-2");
        // [NOTIFY] Order created: ORD-2
    }
}

Das ist der echte „Sieg" des DI-Designs. Wir haben OrderPlacementService nicht umgeschrieben, haben keine Flags boolean auditEnabled hinzugefügt, kein if(mode == TEST) arrangiert. Wir haben einfach gesagt: „Dieses Mal wird die Rolle AuditWriter von einem anderen Objekt gespielt".

Um das Bild zu festigen, hier das Schema des Objektgraphen (und ja, das ist immer noch Plain Java, wir haben es nur sauber beschrieben):

flowchart LR
    Main["Main (Composition Root)"] --> OPS["OrderPlacementService"]
    OPS -->|NotificationSender| CNS["ConsoleNotificationSender"]
    OPS -->|AuditWriter| CAW["ConsoleAuditWriter / NoOpAuditWriter"]

Und genau wegen solcher Schemata lieben die Leute in echten Projekten Container: Der Graph wächst, und alles manuell zu verwalten wird schwer. Aber vorerst lernen wir ehrlich, es von Hand zu machen – um zu verstehen, was genau der Container dann automatisieren wird.

4. Wo ein Interface überflüssig ist: Domänendaten und Befehle

Nach ein paar gelungenen Interfaces besteht die Gefahr, in den Modus „Interface-Manie" zu wechseln. Das ist, wenn man Order, IOrder, OrderImpl macht, dann Customer, ICustomer, DefaultCustomer… und plötzlich ist die Anwendung einfacher mit einem Schamanen-Tamburin zu verwalten als mit einer IDE. Damit das nicht passiert, ist es wichtig, eine Grenze im Voraus zu ziehen: Daten und einfache Domänenmodelle sind meistens kein Variabilitätspunkt.

Eine Bestellung sind Daten. Ein Kunde sind Daten. Ein Bestellerstellungsbefehl sind Eingabedaten. Sie sollen nicht „Implementierungen austauschen", sie sollen klar und einfach sein. Deshalb sind das meistens gewöhnliche Klassen (oder Records, wenn man die mag).

Beispiel eines minimalen Order ohne Interface:

package com.example.contextflow.domain.model;

public class Order {
    // Domänendatenmodell: einfaches, transparentes Objekt ohne Implementierungsvariabilität
    private final String id;
    private final int total;

    public Order(String id, int total) {
        this.id = id;
        this.total = total;
    }

    public String getId() { return id; }
    public int getTotal() { return total; }
}

Und ein einfacher Customer, der für Rabatte gebraucht wird:

package com.example.contextflow.domain.model;

public class Customer {
    // Wieder: einfache Daten, ohne Interfaces und „Plugins"
    private final String id;
    private final boolean loyal;

    public Customer(String id, boolean loyal) {
        this.id = id;
        this.loyal = loyal;
    }

    public boolean isLoyal() { return loyal; }
}

Beachte, wie gut das zu lesen ist: Keine „leeren Abstraktionen". Ja, man kann sich vorstellen, dass man eines Tages verschiedene Implementierungen von Customer möchte… aber das bedeutet meistens, dass man nicht Customer modelliert, sondern verschiedene Datenquellen oder verschiedene Kundendarstellungen – und das ist bereits ein anderes Thema. Auf dem aktuellen Niveau brauchen wir ein transparentes Domänenmodell, und Abstraktionen sollen dort leben, wo sie wirklich den Aufbau steuern.

5. Anzeichen eines schlechten Interfaces und schnelle Korrekturen

Ein Interface ist eine einfache Sache, aber ein schlechtes Interface macht das Projekt schnell unangenehm. Und zwar nicht unangenehm für den Computer – der hält alles aus –, sondern für den Menschen, der den Code liest. Die gute Nachricht: Die meisten Probleme sind auf der Ebene des gesunden Menschenverstands sichtbar: Wenn man ein Interface anschaut und nicht erklären kann, welche Rolle es ausdrückt, drückt es wahrscheinlich auch wirklich „nichts" aus.

Es gibt zwei beliebte Anti-Muster. Das erste – Interface um des Interfaces willen: „Jede Klasse muss ein Interface haben". Dann entstehen Paare OrderService / OrderServiceImpl, wo das Interface keine Variabilität gibt, sondern einfach eine Schicht hinzufügt. Das zweite – das „Kombi-Interface", das versucht, alles einzuschließen, was je gebraucht werden könnte, und am Ende eine kleine Welt mit einem Dutzend Methoden wird.

Beispiel eines Kombi-Interfaces – so will man es nicht machen:

public interface OrderStore {
    void save(Object o);
    void delete(Object o);
    void clearAll();
    void printDebugInfo();
    String exportToJson();
}

Das Problem liegt nicht in der Zeilenanzahl, sondern in der Rollenvermischung. Der Speicher ist plötzlich auch Exporter, Debugger und Aufräumer geworden. Die Korrektur ist oft einfach: Das Interface auf seine Rolle zurückführen. Für unser Lernprojekt reicht zum Beispiel „speichern" und „finden":

import java.util.Optional;

public interface OrderStore {
    // Enge Rolle: Bestellungen speichern (speichern und finden)
    void save(Order order);

    Optional<Order> findById(String orderId);
}

Noch ein guter Test: Versuche, das Interface laut als Satz auszusprechen. „OrderStore speichert und findet Bestellungen." „AuditWriter schreibt Audit-Einträge." „NotificationSender sendet Benachrichtigungen." Wenn dabei ein normaler Satz herauskommt – du bist nah an einer guten Abstraktion. Wenn dabei „OrderManagerDoEverythingProviderFactory" herauskommt – möglicherweise hast du versehentlich einen Zauberspruch statt Code geschrieben.

6. Typische Fehler beim Einführen von Interfaces

Fehler Nr. 1: Interfaces „auf Vorrat" für jede Klasse.
Es ist sehr leicht, in das Muster zu verfallen: „Es gibt eine Klasse – also muss es ein Interface geben". Am Ende entstehen leere Abstraktionen, die weder Variabilität noch Klarheit geben, aber die Navigation im Code erschweren. Besser ist es, ein Interface dort einzuführen, wo es eine echte Rolle gibt und mindestens potenziell mehrere Implementierungen.

Fehler Nr. 2: Benennung nach der Implementierung, nicht nach der Rolle.
Wenn das Interface ConsoleAuditWriter heißt, ist das kein Rollen-Interface mehr – das ist ein Detail-Interface. Das Interface soll um das „Was tun" gehen: AuditWriter, NotificationSender, OrderStore. Und die Konkretisierung – „console", „file", „noop" – lebt im Namen der Implementierung.

Fehler Nr. 3: Der Business-Service wählt die Implementierung trotzdem über if.
Manchmal macht man ein Interface NotificationSender… und schreibt dann innerhalb von OrderPlacementService ein if(channel.equals("console")) sender = new Console.... Das bringt uns zu demselben Schmerz zurück, von dem wir weggegangen sind: Die Klasse trifft wieder die Entscheidung über die Implementierung. Die Wahl der Implementierung soll im Composition Root leben – im Zusammenbaupunkt –, nicht innerhalb des Szenarios.

Fehler Nr. 4: Das Interface wurde zum „Gott", weil es einem Service bequemer ist.
Wenn ein Service eine „weitere Methode" wollte und man sie dem gemeinsamen Interface hinzugefügt hat, zwang man alle Implementierungen, diese neue Pflicht zu unterstützen. Daher wachsen seltsame Konstruktionen wie throw new UnsupportedOperationException() oder gefälschte Stubs. Oft ist es besser, ein zweites kleines Interface für eine separate Rolle zu machen, als eines aufzublähen.

Fehler Nr. 5: Interfaces sind in Domänendaten eingesickert.
Wenn man IOrder, ICustomer, IOrderItem macht, macht das System normalerweise nicht flexibler – macht es aber weniger lesbar. Domänendatenobjekte bleiben fast immer gewöhnliche Klassen, und Interfaces tauchen um externe Auswirkungen auf – Benachrichtigungen, Audit, Speicherung – oder um variable Strategien – Rabatt, ID-Generierung.

1
Aufgabe
Spring Core, Level 2, Lektion 3
Gesperrt
Austauschbarer Benachrichtigungsabsender
Austauschbarer Benachrichtigungsabsender
1
Aufgabe
Spring Core, Level 2, Lektion 3
Gesperrt
Schnittstelle für Verhalten, record für Daten
Schnittstelle für Verhalten, record für Daten
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION