1. Field Injection: Bequemer Start
Field Injection wirkt oft wie der freundlichste Einstieg in Spring: eine Annotation hinzugefügt – und „alles von selbst“. Für Anfänger erinnert es an eine Steckdose: hier der Stecker (@Autowired), hier der Strom (bean), und warum Strom überhaupt funktioniert – egal, Hauptsache die Glühbirne leuchtet. Das Problem ist, dass sie bis zur ersten ernsthaften Refaktorisierung, zum Unit-Test oder zum Start ohne Container nur „leuchtet“.
Die typischste Form von Field Injection in einem Service sieht so aus:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AuditService {
// Spring setzt die Abhängigkeit via Reflection NACH der Objekterstellung ein
@Autowired
private AuditWriter auditWriter;
public void audit(String message) {
// Wenn die Abhängigkeit nicht injiziert wurde (z.B. Objekt mit new erstellt),
// dann gibt es hier einen NullPointerException
auditWriter.write(message);
}
}
Warum scheint das bequem? Weil man nichts in den Konstruktor schreiben muss. Die Klasse hat einen „kostenlosen“ leeren Konstruktor (man sieht ihn nicht, aber er existiert), die IDE markiert nichts rot, und in einem kleinen Beispiel funktioniert es tatsächlich: Der Container erstellt das Objekt, setzt dann via Reflection das Feld, und die Methode audit() ruft auditWriter auf.
An dieser Stelle kommt meist der Gedanke: „Warum überhaupt der lange Konstruktor, wenn es so geht – zwei Wörter und fertig?“ Und genau hier wird es interessant. Field Injection ist zwar kürzer, aber diese „Buchstabenersparnis“ erkauft man sich durch sehr konkrete technische Nachteile: Der Klassenvertrag wird versteckt, das Objekt kann in einem ungültigen Zustand existieren, die Abhängigkeit kann nicht final gemacht werden, das Testen ist umständlicher, und Refaktorisierungen erkennen Probleme später, als wünschenswert.
2. Field Injection verbirgt den Klassenvertrag
Wenn Sie eine Servicedatei öffnen, möchten Sie in 10 Sekunden zwei Dinge verstehen: „Was macht die Klasse“ und „Ohne was funktioniert sie nicht“. Im Idealfall ist die Antwort auf die zweite Frage der Konstruktor. Bei Field Injection gibt es diesen „einheitlichen Zugang“ nicht: Die Abhängigkeiten sind über die Felder verstreut, und Sie müssen mit den Augen die gesamte obere Klasse „scannen“, manchmal auch nicht nur den oberen Teil.
Vergleichen wir zwei Varianten am Beispiel eines Service aus ContextFlow. Zuerst Field Injection:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
// Die Abhängigkeiten sind nicht offensichtlich: man muss sie mit den Augen in den Feldern suchen
@Autowired private OrderIdGenerator orderIdGenerator;
@Autowired private OrderStore orderStore;
@Autowired private OrderPricingService pricingService;
// ...
}
Und nun Constructor Injection (gleicher Sinn, aber der Vertrag ist sofort sichtbar):
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
// An final erkennt man: Die Abhängigkeit ist obligatorisch und wird bei der Objekterstellung festgelegt
private final OrderIdGenerator orderIdGenerator;
private final OrderStore orderStore;
// Konstruktor = expliziter Vertrag: Ohne diese Abhängigkeiten wird der Service nicht erstellt
public OrderPlacementService(OrderIdGenerator orderIdGenerator, OrderStore orderStore) {
this.orderIdGenerator = orderIdGenerator;
this.orderStore = orderStore;
}
}
Um den Unterschied zu spüren, versuchen Sie rein gedanklich das Spiel „Ich bin ein neuer Entwickler im Team und habe ein Ticket zur Änderung eines Szenarios bekommen“. In der Constructor-Variante sehe ich sofort: Dieser Service hängt von OrderIdGenerator und OrderStore ab. In der Field-Variante werde ich das auch sehen … aber nur, wenn die Felder nebeneinander liegen, ordentlich formatiert sind, niemand sie in der Datei verstreut und niemand später noch ein paar @Autowired weiter unten „einfach so“ hinzugefügt hat.
Hier ist eine kurze Tabelle (keine Liste um der Liste willen, sondern ein „Gedankenscanner“):
| Frage beim Lesen der Klasse | Constructor Injection | Field Injection |
|---|---|---|
| Wo sind die obligatorischen Abhängigkeiten zu sehen? | An einem Ort: Konstruktor | Man muss in den Feldern suchen |
| Kann der Vertrag ohne Scrollen verstanden werden? | Oft ja | Oft nein |
| Kann die Abhängigkeit unveränderlich gemacht werden (final)? | Ja | Normalerweise nein |
| Was macht die Klasse „obligatorisch“? | Das, was in den Konstruktorparametern steht | Unklar: Feld ist vorhanden, aber es „sollte da sein“ |
Eine lustige, aber treffende Analogie: Constructor Injection ist, wenn auf der Schachtel steht: „Für den Zusammenbau des Schranks werden 8 Schrauben und 2 Türen benötigt.“ Field Injection ist, wenn die Türen „später“ kommen sollen und Sie es erst merken, wenn der Schrank schon im Raum steht und Sie feststellen, dass er ein wenig „durchzieht“.
3. Unvollständig erstelltes Objekt und später NullPointerException
Eine der unangenehmsten Eigenschaften von Field Injection ist, dass man das Objekt mit einem einfachen new erstellen kann und Java denkt, alles sei in Ordnung. Das bedeutet, dass „falsche Verwendung“ nicht vom Compiler erkannt wird und auf der API-Ebene der Klasse nicht wie ein Fehler aussieht. Der Fehler taucht später auf – als NullPointerException – und oft an einer Stelle, die überhaupt nicht nach „DI-Fehler“ aussieht.
Sehen wir uns das live an. Unser AuditService mit Field Injection kann so erstellt werden:
public class Demo {
public static void main(String[] args) {
// Objekt manuell erstellt – Spring ist nicht da
AuditService service = new AuditService();
// auditWriter bleibt null, daher gibt es hier einen NullPointerException
service.audit("order created");
}
}
Ja, der Code kompiliert. Ja, das Objekt wird erstellt. Und ja, beim Aufruf von audit() erhalten Sie einen NullPointerException, weil auditWriter niemand gesetzt hat: Der Spring-Container ist nicht da und das Feld ist privat.
In der Constructor-Variante ist dieses Szenario unmöglich: Sie können das Objekt ohne Abhängigkeit physisch nicht erstellen.
// Abhängigkeit explizit erstellen (oder einen Stellvertreter im Test einsetzen)
AuditWriter writer = new ConsoleAuditWriter();
// Ohne writer wird das Objekt nicht erstellt – das ist ein Fail-Fast auf Compiler/IDE-Ebene
AuditService service = new AuditService(writer);
Ein wichtiger Punkt: „Aber wir sind doch in Spring, warum sollten wir new verwenden?“ – eine gute Frage. Die Antwort ist einfach: Wir wollen ständig die Möglichkeit haben, ein Objekt außerhalb des Containers zu erstellen, zumindest in zwei alltäglichen Situationen. Erstens – Unit-Test (darauf kommen wir zurück). Zweitens – manuelle Überprüfung/Demo in Plain Java, wenn Sie ein Stück Logik debuggen und nicht den gesamten Kontext hochfahren möchten.
Damit das nicht abstrakt klingt, hier ein Diagramm, was bei Field Injection im Container tatsächlich passiert:
sequenceDiagram
participant C as Spring-Container
participant S as AuditService
participant W as AuditWriter
%% Schritt 1: Objekt wird ohne Abhängigkeiten erstellt
C->>S: new AuditService()
Note over S: auditWriter = null
%% Schritt 2: Container findet/erstellt den benötigten Bean
C->>W: Hole Bean AuditWriter
%% Schritt 3: Container füllt das Feld via Reflection
C->>S: set field auditWriter via reflection
Note over S: auditWriter != null
C-->>S: Bean bereit
Achten Sie auf ein wichtiges Detail: Das Objekt wird zuerst erstellt und die Abhängigkeiten kommen später. Das ist der „unvollständige Zustand“. Innerhalb von Spring wird das kontrolliert, aber sobald das Objekt aus diesem „Aufbaukorridor“ herausgenommen wird (z.B. versucht man, es manuell zu erstellen), wird es wieder potenziell ungültig.
4. Abhängigkeit kann nicht final gemacht werden
final für Abhängigkeiten ist nicht „Schönheit um der Schönheit willen“. Es ist ein einfacher und praktischer Schutz: Wenn eine Abhängigkeit obligatorisch ist, sollte sie sich nach der Objekterstellung nicht ändern. Das hält die Klasse in einem verständlichen Zustand, reduziert die Wahrscheinlichkeit zufälliger Fehler und erleichtert das Lesen: Wenn ein Feld final ist, bedeutet das, dass es im Konstruktor gesetzt wurde und während der Lebensdauer des Objekts nicht plötzlich durch etwas anderes ersetzt wird.
Bei Field Injection können Sie das Feld in der Regel nicht final machen, weil final im Konstruktor (oder Initialisierer) zugewiesen werden muss, Spring das Feld aber später setzt.
So geht es nicht (und das ist gut so):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AuditService {
// final erfordert Initialisierung im Konstruktor, aber bei Field Injection macht Spring das später
@Autowired
private final AuditWriter auditWriter; // so nicht möglich
public void audit(String message) {
auditWriter.write(message);
}
}
Aus diesem Grund sehen viele Services mit Field Injection so aus:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AuditService {
// Feld kann nicht final sein -> es entsteht das Risiko einer „zusätzlichen Initialisierung“ woanders
@Autowired
private AuditWriter auditWriter; // nicht final
public void audit(String message) {
auditWriter.write(message);
}
}
„Na und, ich werde es ja nicht ändern“ – wieder ein normaler Gedanke. Aber hier kommen der menschliche Faktor und Refaktorisierungen ins Spiel. Heute werden Sie es „nicht ändern“, aber in drei Wochen fügt jemand eine Methode setAuditWriterForTest() hinzu (ja, auch das kommt vor), weil „schnell getestet werden muss“. Oder jemand fügt einfach eine weitere Initialisierungsstelle hinzu, und das Objekt wird zu einem Puzzle.
Constructor Injection bringt die Normalität zurück: final-Felder, und die Abhängigkeit wird einmal festgelegt.
import org.springframework.stereotype.Service;
@Service
public class AuditService {
// final unterstreicht: Die Abhängigkeit ist obligatorisch und nach der Erstellung unveränderlich
private final AuditWriter auditWriter;
public AuditService(AuditWriter auditWriter) {
// Der Konstruktor – die einzige Stelle, an der wir das Objekt „zusammenbauen“
this.auditWriter = auditWriter;
}
}
Das sieht nach „mehr Code“ aus, aber in der Praxis ist es weniger Ungewissheit. Und Ungewissheit ist die teuerste Ressource in einem Team (nach Kaffee).
5. Unit-Tests ohne Spring und Reflection
Die Schwachstelle von Field Injection zeigt sich besonders deutlich in Unit-Tests. Ein Unit-Test soll schnell ein Objekt erstellen, ihm eine Abhängigkeit als Stellvertreter unterjubeln und das Verhalten prüfen. Wenn die Abhängigkeit im Konstruktor ist, geht das so einfach wie das Zusammenbauen eines IKEA-Hockers (ja, ich weiß, der Vergleich ist gewagt, aber hier kommt man ohne Inbusschlüssel aus).
Beispiel eines Tests für Constructor Injection:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AuditServiceTest {
@Test
void writesMessage() {
// Stellvertreter statt echter Abhängigkeit
CapturingAuditWriter writer = new CapturingAuditWriter();
// Abhängigkeit explizit übergeben – kein Spring-Kontext nötig
AuditService service = new AuditService(writer);
service.audit("created");
// Überprüfen des beobachteten Effekts
assertEquals("created", writer.lastMessage);
}
}
Und der Stellvertreter selbst – maximal klein:
class CapturingAuditWriter implements AuditWriter {
// Hier „merken“ wir uns die letzte Nachricht, damit der Test das Ergebnis prüfen kann
String lastMessage;
@Override
public void write(String message) {
// Wir emulieren das Schreiben, aber statt I/O speichern wir einfach den Wert
this.lastMessage = message;
}
}
Kein Spring-Kontext. Keine Magie. Reines Java.
Versuchen wir nun dasselbe mit Field Injection. Das Problem ist, dass das Feld auditWriter privat ist und vom Container gesetzt wird. Im Test müssen Sie also entweder Spring hochfahren (was für einen Unit-Test in der Regel zu aufwändig ist) oder in die Reflection einsteigen.
Reflection bedeutet, dass Sie sich im Test wie Spring verhalten, nur ohne das Gehalt des Spring-Teams und ohne die Garantie, dass alles schön läuft. Beispiel (und ja, das sieht nach einer Strafe aus):
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
class AuditServiceFieldInjectionTest {
@Test
void writesMessage() throws Exception {
// Objekt manuell erstellt: Die Abhängigkeit ist nicht injiziert
AuditService service = new AuditService();
// Jetzt imitieren wir, was der Spring-Container tut
Field f = AuditService.class.getDeclaredField("auditWriter");
f.setAccessible(true); // Kapselung für den Test brechen
f.set(service, new CapturingAuditWriter()); // Abhängigkeit manuell „injizieren“
// Jetzt kein NPE, aber der Test hängt vom Feldnamen und den Interna der Klasse ab
service.audit("created");
}
}
Das Problem eines solchen Tests ist nicht, dass er „kompliziert“ ist. Das Problem ist, dass er zerbrechlich ist. Umbenennung des Feldes – Test kaputt. Änderung des Zugriffsmodifikators – kaputt. Hinzufügen von Logik in den Setter/Initialisierer (was es bei Field Injection normalerweise nicht gibt, aber Menschen sind erfinderisch) – wieder Überraschungen.
Und hier taucht eine wichtige praktische Schlussfolgerung für ContextFlow auf: Wenn unsere Service-Schicht constructor-driven ist, können wir Teile der Geschäftslogik in reinem Java testen, ohne den Kontext hochfahren zu müssen. Das ist kein „separater Testkurs“, sondern einfach eine Methode, um nicht jede Überprüfung zu einem „Starte die halbe Anwendung für eine einzige Methode“ zu machen.
6. Refaktorisierung und Fail-Fast
In der Technik gibt es ein beliebtes Prinzip: Besser früh und klar scheitern als spät und rätselhaft. Constructor Injection hilft, dieses Prinzip fast automatisch umzusetzen. Field Injection hingegen schafft weichen Boden für „leise“ Änderungen, die der Compiler nicht bemerkt.
Stellen Sie sich vor, Sie erweitern AuditService und fügen eine weitere obligatorische Abhängigkeit hinzu (egal welche, sagen wir einen Nachrichtenformatierer). In der Constructor-Variante sind Sie gezwungen, den Konstruktor zu aktualisieren, und damit alle Stellen, an denen das Objekt erstellt wird (einschließlich @Bean-Methoden und Unit-Tests). Das ist unangenehm … aber genau das ist Fail-Fast: Der Compiler zeigt Ihnen selbst alle Stellen an, die korrigiert werden müssen.
In der Field-Injection-Variante fügen Sie einfach ein weiteres Feld hinzu:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AuditService {
// Äußerlich „hat sich nichts geändert“: Der Konstruktor ist immer noch leer
@Autowired private AuditWriter auditWriter;
@Autowired private AuditMessageFormatter formatter;
public void audit(String message) {
// Auf Compiler-Ebene ist nicht sichtbar, dass formatter obligatorisch ist
auditWriter.write(formatter.format(message));
}
}
Was passiert als Nächstes? Der Code kompiliert. Wenn Sie Plain-Java-Tests haben, die new AuditService() gemacht haben, kompilieren diese auch (und beginnen noch fröhlicher mit NPE zu fallen). Und wenn der Kontext keinen AuditMessageFormatter enthält, stürzt die Anwendung beim Start ab. Das ist schon besser (der Container fängt das Problem immerhin), aber Sie haben immer noch eine Situation, in der der Klassenvertrag nicht in der Signatur abgebildet ist.
Besonders auffällig wird das bei @Bean-Methoden. Mit Constructor Injection „durchdringt“ die Abhängigkeit bis zur Konfiguration. Beispiel:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CoreConfig {
@Bean
public AuditService auditService(AuditWriter auditWriter) {
// Die Abhängigkeit ist hier direkt sichtbar: Die @Bean-Methode muss sie explizit annehmen
return new AuditService(auditWriter);
}
}
Wenn sich der Konstruktor von AuditService ändert, wird die Methode auditService() nicht mehr kompilieren. Und das ist wunderbar: Der Fehler taucht genau dort auf, wo Sie das Wiring tatsächlich verwalten.
Bei Field Injection kann die @Bean-Methode die Änderung völlig ignorieren: new AuditService() kompiliert immer noch. Aber der Service selbst hängt bereits von dem neuen Feld ab. Ergebnis: Das Wiring „funktioniert scheinbar“, aber beim Start des Kontexts fangen Sie eine UnsatisfiedDependencyException. Das ist keine Katastrophe, aber es ist eine weniger „direkte“ Art, von Fehlern zu erfahren.
Im Schulungsprojekt ContextFlow halten wir den Code bewusst maximal transparent. Und deshalb möchten wir, dass Änderungen an Abhängigkeiten sofort sichtbar sind – im Konstruktor und in der Konfiguration, nicht „irgendwo zur Laufzeit“.
7. Lesbarkeit der Service-Schicht in ContextFlow
Wenn Sie einen Service und zwei Felder haben, wirkt Field Injection erträglich. Aber die Service-Schicht ist der Ort, an dem es viele solcher Klassen gibt, und Sie beginnen, einen einheitlichen Stil zu schätzen. Wenn die Hälfte der Services Constructor Injection ist, ein Viertel Field Injection „weil zu faul“, und noch ein paar haben manuelle new-Aufrufe innerhalb der Methoden, dann hört das Projekt auf, sich wie ein System zu lesen.
Hier ist eine „schlechte“ Variante von OrderCancellationService (nur zur Veranschaulichung, bitte nicht als Norm kopieren):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
// Abhängigkeiten sind in Feldern versteckt und nur bei genauem Hinsehen erkennbar
@Autowired private OrderStore orderStore;
@Autowired private AuditService auditService;
public void cancel(String orderId) {
auditService.audit("cancel " + orderId);
}
}
Jetzt die „gute“ Variante – Abhängigkeiten werden explizit deklariert und sofort festgelegt:
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
// Der Konstruktor sagt sofort: Ohne diese beiden Objekte existiert der Service nicht
private final OrderStore orderStore;
private final AuditService auditService;
public OrderCancellationService(OrderStore orderStore, AuditService auditService) {
this.orderStore = orderStore;
this.auditService = auditService;
}
}
Der Unterschied liegt hier nicht in der Anzahl der Zeilen. Der Unterschied liegt darin, dass die zweite Klasse wie ein Vertrag gelesen werden kann: Wenn Sie eine Bestellung stornieren möchten, brauchen Sie OrderStore und AuditService. Keine Überraschungen, kein „wo holt er das her“.
Und noch ein wichtiger Punkt: Wenn Sie ContextFlow weiterentwickeln, werden Sie regelmäßig auf die Konstruktoren der Services schauen wie auf eine „Abhängigkeitslandkarte“ der gesamten Anwendung. Das ist fast wie pom.xml, nur auf Objektebene. Field Injection zerstört diese Karte, weil die Abhängigkeiten „nicht dort sind, wo Sie sie erwarten“.
8. Typische Fehler bei der Verwendung von Field Injection
Selbst wenn Sie bereits zugestimmt haben, dass Field Injection ein schlechter Standard ist, ist es nützlich, es an seinem „Gang“ zu erkennen und zu verstehen, welche Fehler es provoziert. In echten Projekten werden Sie diesen Stil trotzdem antreffen, besonders in Legacy-Code und alten Tutorials. Die Mindestaufgabe ist daher, keine Angst zu haben und erklären zu können, warum er den Code schlechter macht.
Fehler Nr. 1: Field Injection nur wählen, weil es kürzer ist.
Kurzer Code schreibt sich angenehm, aber jemand wird ihn später lesen, warten und testen (und dieser Jemand sind manchmal Sie selbst in drei Monaten). Field Injection spart heute ein paar Zeilen und schafft eine regelmäßige „Rente“ beim Lesen, Testen und Refaktorisieren von morgen. In der Service-Schicht ist es besser, eine klare, verständliche Norm zu halten: Abhängigkeiten leben im Konstruktor.
Fehler Nr. 2: Zu glauben, dass ein private Feld bedeutet, dass „niemand es kaputt machen kann“.
private schützt vor direktem Zugriff aus anderen Klassen, löst aber nicht das Kernproblem: Die Abhängigkeit ist kein Teil des öffentlichen Klassenvertrags. Außerdem setzt Spring das Feld trotzdem via Reflection, sodass die Klasse bereits auf einer „innereren“ Ebene vom Container abhängt als bei Constructor Injection.
Fehler Nr. 3: Constructor Injection und Field Injection im selben Service mischen.
Manchmal macht ein Entwickler es „teilweise richtig“: Wichtige Abhängigkeiten packt er in den Konstruktor, und noch ein paar „für den Fall der Fälle“ wirft er über @Autowired-Felder dazu. Das Ergebnis: Die Klasse ist nicht mehr ehrlich – sie hat einen sichtbaren Vertrag (Konstruktor) und einen unsichtbaren Anhang (Felder). Der Leser wird unweigerlich Fehler machen, weil er denkt, er hätte im Konstruktor alles gesehen.
Fehler Nr. 4: Ein Objekt mit new erstellen und hoffen, dass es „wie in Spring“ funktioniert.
Field Injection macht das besonders gefährlich, weil new kompiliert und legal aussieht. Aber das Objekt wird in einem unvollständigen Zustand sein, und der Fehler taucht später auf, meist als NullPointerException. Constructor Injection ist hier ehrlicher: Wenn eine Abhängigkeit obligatorisch ist, können Sie nicht vergessen, sie zu übergeben.
Fehler Nr. 5: Die Service-Schicht in einen „Annotations-Teppich“ verwandeln.
Wenn @Autowired über jedem zweiten Feld steht, hört die Klasse auf, eine Klasse zu sein, und beginnt wie eine „Deklaration dessen, was der Container eines Tages bringen wird“ auszusehen. Das senkt den Fokus auf den geschäftlichen Sinn und verstärkt das Gefühl von „Magie“. Auf der Ebene von ContextFlow lernen wir bewusst, Wiring als gewöhnliche technische Konstruktion zu sehen, nicht als eine Ansammlung von Zaubersprüchen.
GO TO FULL VERSION