In vielen Softwaresystemen führt die Notwendigkeit, verschiedene Geschäftsregeln auf Grundlage von unterschiedlichen Bedingungen anzuwenden, oft zu komplexem und schwer wartbarem Code. Ohne ein flexibles Design können Strukturen wie ein if-else
oder ein switch-case
schnell unübersichtlich werden - insbesondere wenn das System wächst. Dieser Post zeigt, wie dieses Problem gelöst werden kann. Mithilfe von Spring’s Dependency Injection (DI) und Java Streams wird ein Ansatz vorgestellt, der die Auswahl von Services vereinfacht. Dadurch entsteht ein modularerer und leichter wartbarer Code, der sich problemlos erweitern lässt - selbst wenn das System wächst.
Nehmen wir an, eine E-Commerce-Plattform muss je nach Land des Käufers unterschiedliche Steuer- oder Versandvorschriften anwenden. Um den Code modular und testbar zu halten, wird für jedes Land ein eigener Service erstellt. Trotzdem erfolgt die Auswahl des jeweiligen Services weiterhin über eine einfache if-else
- oder switch-case
-Strukturen. Auf den ersten Blick scheint dies praktikabel – jeder Service wird basierend auf dem Standort des Käufers ausgewählt.
Doch sobald die Plattform wächst und weitere Länder hinzukommen, wird diese Lösung zunehmend problematisch. Je mehr Länder verwaltet werden müssen, desto komplexer wird der Codeblock. Das Ergebnis: ohne ein skalierbares Design entsteht ein stark gekoppelter, monolithischer Code, der anfällig für Fehler, schwer zu testen, sowie schwer zu erweitern ist.
Doch keine Sorge – dies ist ein häufiges Problem in Softwaresystemen. Und die gute Nachricht ist: Es gibt skalierbare Lösungen, um dem entgegenzuwirken. Dieser Post zeigt eine davon: den Einsatz von Schnittstellen und der Dependency Injection von Spring, um ein sauberes, modulares System zu entwerfen. Anhand des oben genannten Beispiels wird veranschaulicht, wie diese Prinzipien den Code vereinfachen und gleichzeitig dafür sorgen, dass er flexibel und wartbar bleibt – auch wenn das System wächst.
Nehmen wir an, ein Service zur Abwicklung von Regulierungsprüfungen verwendet einen „Switch-Case“-Block, um die Logik an länderspezifische Services zu delegieren. Je nach Standort des Käufers wird der passende Service ausgewählt.
Dieser Ansatz funktioniert gut, solange nur wenige Länder verwaltet werden. Doch sobald die Anzahl der Länder wächst, steigt auch die Komplexität des „Switch-Case“-Blocks, was zu den bereits genannten Problemen führt: Der Code wird monolithisch, schwer wartbar, anfällig für Fehler und schwer zu erweitern.
@Component @RequiredArgsConstuctor public class CheckRegulationsService { private final USARegulationService usaRegulationService; private final UKRegulationService ukRegulationService; private final GermanyRegulationService germanyRegulationService; private final FranceRegulationService franceRegulationService; public String checkRegulations(String country) { switch (country.toUpperCase()) { case "USA": return usaRegulationService.check(); case "UK": return ukRegulationService.check(); case "DE": return germanyRegulationService.check(); case "FR": return franceRegulationService.check(); default: throw new IllegalArgumentException("Unsupported country: " + country); } } }
Um dieses Problem zu lösen und den Code skalierbarer zu gestalten, bietet sich die Dependency Injection von Spring an. Eine Liste von Services kann injiziert werden, und der passende Service wird dynamisch mithilfe von Streams ausgewählt. Dieser Ansatz ist flexibel und lässt sich einfach erweitern, ohne den bestehenden Code anzupassen. Die Lösung lässt sich folgendermaßen umsetzen:
Zuerst wird ein Interface RegulationService
definiert, die von allen länderspezifischen Services implementiert wird:
public interface RegulationService { String getCountryCode(); String check(); }
Dieses Interface sorgt für eine einheitliche Struktur aller Services, was die Wartung und das Verständnis des Codes erleichtert. Standardisierte Methodensignaturen machen den Zweck eines jedes Services klarer, was die Lesbarkeit verbessert. Zudem bildet das Interface die Basis dafür, dass Spring alle länderspezifischen Services erkennt und als Liste injizieren kann.
Nachdem das Interface definiert wurde, wird es für jeden länderspezifischen Service implementiert. Die dabei resultierenden Services könnten wie folgt aussehen:
@Service public class USARegulationService implements RegulationService { @Override public String getCountryCode() { return "USA"; } @Override public String check() { return "USA regulations applied"; } } @Service public class UKRegulationService implements RegulationService { @Override public String getCountryCode() { return "UK"; } @Override public String check() { return "UK regulations applied"; } }
Jeder Service ist für die spezifische Regulierungslogik seines Landes zuständig, folgt aber einem einheitlichen Vertrag. Will man nun ein neues Land hinzufügen, muss man nur einen neuen Service erstellen, der sich vom Interface ableitet – ohne den bestehenden Code anzupassen.
Sobald alle Implementierungen des Interfaces vorhanden sind, kann eine Liste der Services injiziert werden. Mithilfe von Streams kann diese Liste gefiltert und der passende Service ausgewählt werden. Um den Hauptservice dabei übersichtlich zu halten, wird diese Auswahllogik in eine separate Klasse ausgelagert. Diese separate Klasse nennen wir RegulationServiceSelector
.
@Component @RequiredArgsConstructor public class RegulationServiceSelector { private final List<RegulationService> regulationServices; public RegulationService getRegulationService(String country) { return regulationServices.stream() .filter(service -> service.getCountryCode().equalsIgnoreCase(country)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("No regulation service found for country: " + country)); } }
Der RegulationServiceSelector
filtert die Services anhand des Ländercodes und gibt den passenden Service zurück. Wird kein passender Service gefunden, so wirft er eine Exception.
Nun wird der CheckRegulationsService
so angepasst, dass der RegulationServiceSelector
für die dynamische Auswahl des passenden Services genutzt wird. Dadurch wird der bisher benötigte „Switch-Case“-Block überflüssig.
@Service @RequiredArgsConstructor public class CheckRegulationService { private final RegulationServiceSelector regulationServiceSelector; public String checkRegulations(String country) { final RegulationService regulationService = regulationServiceSelector.getRegulationService(country); return regulationService.check(); } }
RegulationServiceSelector
übernimmt die Auswahllogik, der Hauptservice orchestriert die Aufrufe, und die länderspezifischen Services verwalten ihre Regeln.Durch die Verwendung von Spring’s Dependency Injection und Java Streams lässt sich ein flexibler und simpel wartbarer Mechanismus zur Auswahl von Regulierungsservices entwickeln. Dieses Muster ist besonders hilfreich, wenn unterschiedliche Geschäftsregeln basierend auf Bedingungen wie Ländern, Kundentypen oder Produktkategorien angewendet werden müssen. Es sorgt für eine vereinfachte Codebasis und erleichtert die Skalierung sowie Erweiterung, wenn neue Anforderungen entstehen.
This post was originally published in English on Medium.com.
Co-Author: Andreas Riepl
Rechtliches