Architektur, Erweiterungs-API und Vaadin-Integration.
Einleitung und Motivation
Das Open-Core-Modell wird in der Diskussion häufig auf Fragen der Lizenzierung oder der Vermarktung reduziert. Eine quelloffene Variante einer Software wird durch eine kommerziell vertriebene Variante ergänzt, die zusätzliche Funktionen bietet. Wer jedoch eine solche Trennung in einer bestehenden Codebasis tatsächlich umsetzen möchte, stellt rasch fest, dass die eigentliche Schwierigkeit weder beim Lizenztext noch bei der Wahl des Repositorys liegt, sondern in der Architektur des Codes selbst.
Die zentrale Frage lautet, welche Abhängigkeitsrichtungen zulässig sind und welche unter allen Umständen verhindert werden müssen. Die Erweiterung darf auf den Kern zugreifen, der Kern hingegen darf von der Existenz der Erweiterung nichts wissen. In der Theorie ist diese Aussage selbstverständlich. In der Praxis erlebt man jedoch immer wieder, dass eine unscheinbare Hilfsklasse, ein verbreiteter Konfigurationsmechanismus oder eine bequeme Annotation diese Richtung im Laufe der Zeit unbemerkt umkehrt. Damit ist das Thema dieses Beitrags umrissen: die Frage, wie sich ein Vaadin-basiertes Open-Core-Produkt so aufbauen lässt, dass die Trennung nicht nur dokumentiert ist, sondern auch unmittelbar im Quellcode sichtbar bleibt.
Für die Umsetzung wird bewusst auf Spring und auf eine Jakarta-EE-Plattform verzichtet. Diese Entscheidung ist keine Wertung gegenüber den genannten Plattformen, sondern eine didaktische Entscheidung. Beide neigen dazu, das Auffinden und Verdrahten von Komponenten so weit zu erleichtern, dass die Frage, woher eine Klasse zur Laufzeit eigentlich stammt und über welchen Weg sie in den Anwendungskontext gelangt, in den Hintergrund tritt. Genau diese Frage ist im Open-Core-Kontext jedoch zentral. Wer eine Komponentenklasse mit einer Annotation versehen und sie über den Klassenpfad-Scan einsammeln lässt, erhält eine bequeme Lösung, verliert dabei jedoch die Sichtbarkeit darüber, welche Beiträge aus welchem Modul stammen. Ein expliziter Mechanismus, in diesem Fall der ServiceLoader der Java-Standardbibliothek, zwingt den Autor des Kerns ebenso wie den Autor der Erweiterung dazu, die Beitragsrichtung zu benennen.
Eine Abgrenzung sei vorweggenommen, da sie regelmäßig zu Missverständnissen führt. Vaadin Flow benötigt einen Servlet-Container, und die Servlet-API wird im Beispiel transitiv über Vaadin und einen eingebetteten Jetty in den Klassenpfad aufgenommen. Die Anwendung selbst ist jedoch keine Jakarta-EE-Anwendung. Sie verwendet weder CDI noch JPA, weder EJB noch JAX-RS, und sie ist nicht auf das Vorhandensein eines Anwendungsservers angewiesen.
Als fachlicher Gegenstand dient ein bewusst triviales Beispiel, ein Zähler mit drei Operationen und einem zugehörigen Ereignismodell. Diese Trivialität ist beabsichtigt. Sie verhindert, dass die architektonische Aussage durch fachliche Detailfragen verdeckt wird, und stellt sicher, dass jeder Schritt der Erweiterungsmechanik unmittelbar nachvollziehbar bleibt. Was am Ende entsteht, ist kein realistisches Geschäftsprodukt, sondern eine vollständig durchgearbeitete architektonische Demonstration dessen, wie ein Open-Core-Vaadin-System mit den Mitteln des JDK aufgebaut werden kann.
Sämtliche im Folgenden vorgestellten Quelltexte sind auf GitHub veröffentlicht und
unter https://3g3.eu/naityh abrufbar.
Der Beitrag lässt sich unabhängig davon lesen. Wer einzelne Stellen im vollständigen Zusammenhang nachvollziehen möchte, findet dort den ungekürzten Stand der Implementierung.
Architekturüberblick und Projektstruktur
Die im vorigen Kapitel geforderte Sichtbarkeit der Abhängigkeitsrichtungen beginnt nicht im Quellcode, sondern in der Projektstruktur. Die hier vorgestellte Beispielanwendung besteht aus zwei eigenständigen Maven-Projekten, counter-community und counter-enterprise. Beide sind vollwertige, einzeln baubare Artefakte mit eigenem POM, eigenem Versionsfortschritt und eigener Veröffentlichungsstrategie. Sie sind nicht durch eine gemeinsame Hierarchie verbunden, sondern lediglich durch eine schlichte Abhängigkeit auf Bibliotheksebene.
Die zulässige Abhängigkeitsrichtung verläuft ausschließlich von der Enterprise-Erweiterung zur Community-Edition. Die Enterprise-Erweiterung nimmt die Community als Bibliothek auf, nutzt deren Erweiterungs-API und greift auf deren Layout-Klassen zu. Die Community hingegen kennt die Enterprise-Erweiterung weder zur Kompilierung noch zur Laufzeit. Diese Asymmetrie ist die architektonische Grundaussage des gesamten Vorhabens. Sie ist keine Übereinkunft, deren Einhaltung allein durch Disziplin gesichert wäre, sondern eine bauseits erzwungene Eigenschaft. Da das Community-Projekt in seinem POM keinerlei Verweis auf das Enterprise-Projekt enthält, kann der Quellcode der Community-Edition Enterprise-Klassen schlicht nicht referenzieren. Was beim Bauen nicht im Klassenpfad steht, lässt sich in keiner Importanweisung benennen.

Diagramm 1: Abhängigkeitsrichtung der Maven-Module
Diese Trennung ließe sich grundsätzlich auch innerhalb eines einzelnen mehrmoduligen Maven-Reaktors umsetzen. Im vorliegenden Beitrag wurde jedoch bewusst der Weg über zwei voneinander unabhängige Projekte gewählt. Der Grund ist nicht technisch, sondern organisatorisch und didaktisch zugleich. Im realen Open-Core-Betrieb werden Kern und kommerzielle Erweiterung üblicherweise in getrennten Repositorys, möglicherweise sogar von unterschiedlichen Teams unter unterschiedlichen Lizenzbedingungen entwickelt. Die hier gewählte Struktur bildet diese Wirklichkeit ab, statt sie hinter einer gemeinsamen Reaktordatei zu verbergen. Die Folge ist, dass der Kern unabhängig veröffentlicht werden kann, ohne dass die Erweiterung mitgeschnürt werden müsste, und dass die Erweiterung gegen eine bestimmte Version des Kerns gebaut wird, die wie jede andere Bibliotheksabhängigkeit benannt ist.
Für die tägliche Arbeit in einer Entwicklungsumgebung ist die strikte Trennung in zwei Repositorys jedoch unbequem. Solange Kern und Erweiterung gemeinsam weiterentwickelt werden, ist es hilfreich, beide Projekte gemeinsam zu öffnen, zu bauen und zu durchsuchen. Aus diesem Grund liegt im Wurzelverzeichnis ein Aggregator-POM vor. Er listet die beiden Module auf und ermöglicht es beispielsweise IntelliJ IDEA, beide Projekte in derselben Sitzung zu öffnen.
Dieser Aggregator-POM ist ausdrücklich kein gemeinsamer Eltern-POM. Die beiden Modul-POMs verweisen nicht über <parent> auf ihn, und sie erben weder Dependency-Management noch Plugin-Konfigurationen von ihm. Diese Unterscheidung wirkt zunächst pedantisch, ist aber bewusst gewählt. Ein gemeinsamer Eltern-POM würde eine technische Verbindung zwischen den beiden Modulen schaffen, was der zuvor formulierten Unabhängigkeit zuwiderlaufen würde. Das Community-Projekt ließe sich ohne den Eltern-POM nicht mehr bauen, und ein Auseinanderwachsen der beiden Projekte hinsichtlich der Versionsstrategie oder der Pluginkonfiguration würde an dieser Hierarchie scheitern. Der Aggregator-POM bleibt daher ein reines Werkzeug der Entwicklungsumgebung, ohne Bedeutung für den Bauvorgang der einzelnen Module.
Die Domäne: Zähler und Ereignisse
Die fachliche Aufgabenstellung der Anwendung ist von ausgeprägter Trivialität. Eine ganzzahlige Größe lässt sich erhöhen, verringern und auf 0 zurücksetzen. Damit ist die Domäne in einem Satz erschöpfend beschrieben. Diese Reduktion auf das Minimale ist, wie bereits in der Einleitung angedeutet, beabsichtigt. Jede fachliche Vertiefung würde die architektonische Aussage des Beitrags eher verdunkeln als schärfen, da sie die Aufmerksamkeit auf Geschäftsregeln lenkte, deren Existenz für die hier behandelten Mechanismen unerheblich ist. Die Erweiterungsmechanik einer Open-Core-Anwendung ist unabhängig davon zu betrachten, ob das erweiterte System einen Zähler, eine Rechnungsstellung oder ein Versorgungsnetz verwaltet.
Der Zustand wird in einer Klasse CounterState gehalten, der einen ganzzahligen Wert verwaltet und drei Methoden anbietet: das Erhöhen um 1, das Verringern um 1 sowie das Zurücksetzen auf 0. Jede dieser Operationen verändert nicht nur den internen Zustand, sondern liefert auch ein Ereignis zurück, das die durchgeführte Änderung beschreibt. Die drei Operationen werden über eine Aufzählung CounterAction mit den Konstanten INCREMENT, DECREMENT und RESET typisiert. Auf diese Weise lässt sich später, etwa in einem Audit-Protokoll, die Ursache einer Wertänderung unmittelbar benennen, ohne den Aufrufweg zur Zustandsklasse rekonstruieren zu müssen.
Das Ereignis selbst ist als unveränderlicher Datensatz CounterChangedEvent modelliert. Es trägt vier Angaben, den vorherigen Wert, den neuen Wert, die ausgelöste Aktion sowie einen Zeitstempel, der bei der Erzeugung des Ereignisses gesetzt wird. Die Wahl eines Java-Records ist hier kein Selbstzweck, sondern eine semantische Aussage. Ein Ereignis ist seinem Wesen nach unveränderlich. Wer es nachträglich verändern könnte, würde die Geschichte umschreiben, was im Audit-Kontext geradezu widersinnig wäre.
Über dieser Zustandsklasse liegt ein dünner Dienst CounterService, der nach außen hin die einzige Anlaufstelle für Zähleroperationen ist. Er reicht die Operationen weiter, nimmt das daraus entstehende Ereignis entgegen und verteilt es an alle registrierten Zuhörer. Diese Trennung zwischen Zustand und Dienst wirkt für die hier behandelte Trivialdomäne überdimensioniert, gewinnt jedoch im weiteren Verlauf an Bedeutung. Der Dienst ist die Stelle, an der die Erweiterungsmechanik des Beitrags ansetzen wird. An ihn können beliebig viele Zuhörer angeschlossen werden, ohne dass die Zustandsklasse selbst von ihrer Existenz wissen muss.
Damit ist die fachliche Grundlage für alles Folgende gelegt. Die Community-Edition wird den Zähler über Vaadin sichtbar machen und die drei Operationen als Schaltflächen bereitstellen. Die Enterprise-Erweiterung wird sich nicht in die Domäne selbst einmischen, sondern lediglich als Zuhörer fungieren, der jedes Ereignis abfängt und für seine eigenen Zwecke verwertet, sei es für eine Historienansicht, ein Audit-Protokoll oder eine Export-Sicht. Mehr als diese kurze Skizze wird an dieser Stelle nicht benötigt.
Das Erweiterungs-API
Die Erweiterungs-API ist die Vertragsfläche, an der Kern und Erweiterung aufeinandertreffen. Es legt fest, in welcher Form ein Modul sich in eine laufende Anwendung einbringen darf, und es legt zugleich fest, was es nicht darf. Beide Aspekte sind gleichermaßen wichtig. Eine zu mächtige Erweiterungs-API mag kurzfristig flexibel wirken, untergräbt aber jene Eigenschaften, um derentwillen das Open-Core-Modell überhaupt gewählt wurde, namentlich die Vorhersagbarkeit des Kerns gegenüber beliebigen Erweiterungen.
Im Zentrum steht die Schnittstelle FeatureContribution. Eine Implementierung dieser Schnittstelle repräsentiert genau eine funktionale Einheit, die der Anwendung beitritt. Sie trägt einen sprechenden Identifikator, der ihre Herkunft kenntlich macht, sowie drei Beiträge, die in das Gesamtsystem eingebracht werden können: eine Liste von Routen, eine Liste von Menüeinträgen und eine Liste von Ergänzungen für die Navigationsleiste. Hinzu kommt eine optionale Sortierreihenfolge, anhand deren die Beiträge bei der Zusammenstellung geordnet werden. Mehr enthält die Schnittstelle nicht. Sie ist bewusst auf das Beschreibende beschränkt und enthält keine Methode, die das System aktiv verändern könnte. Die Methoden für Navigationsleisten-Ergänzungen sowie die Sortierreihenfolge verfügen über Standardimplementierungen, sodass eine Erweiterung nur jene Methoden überschreiben muss, deren Beiträge sie tatsächlich interessieren.
public interface FeatureContribution {
String id();
List<RouteContribution> routes();
List<MenuContribution> menuItems();
default List<NavbarContribution> navbarItems() {
return List.of();
}
default int order() {
return 1000;
}
}
Die beiden Beitragstypen RouteContribution und MenuContribution sind als unveränderliche Datensätze ausgestaltet. Eine RouteContribution trägt den relativen Pfad und die zugehörige Vaadin-Komponentenklasse, eine MenuContribution trägt zusätzlich ein Etikett für die Anzeige, eine eigene Sortierreihenfolge sowie einen Icon-Bezeichner. Beide sind als record modelliert, da sie reine Beschreibungsobjekte ohne Verhalten sind. In beiden Fällen ist ein kompakter Konstruktor hinterlegt, der die Pflichtangaben gegen null absichert und im Falle der Route zusätzlich verhindert, dass der Pfad mit einem führenden Schrägstrich beginnt.
public record RouteContribution(
String path,
Class<? extends Component> viewClass
) {
public RouteContribution {
Objects.requireNonNull(path, "path");
Objects.requireNonNull(viewClass, "viewClass");
if (path.startsWith("/")) {
throw new IllegalArgumentException(
"Route path must not start with '/': " + path);
}
}
}
public record MenuContribution(
String label,
String path,
int order,
String iconName
) {
public MenuContribution {
Objects.requireNonNull(label, "label");
Objects.requireNonNull(path, "path");
}
}
Der dritte Beitragstyp, NavbarContribution, weicht von diesem Muster bewusst ab. Er ist nicht als Record, sondern als Schnittstelle konzipiert. Der Grund liegt in einer Eigenheit von Vaadin. Eine Vaadin-Komponente kann jederzeit nur einem einzigen Elternteil im Komponentenbaum zugeordnet sein. Würde die Erweiterung eine fertige Komponenteninstanz als Beitrag liefern, ließe sich diese nicht zuverlässig mehreren UI-Instanzen zuordnen. Aus diesem Grund liefert eine NavbarContribution keine fertige Komponente, sondern eine Fabrikfunktion in Form eines Supplier<Component>, die bei jeder Verwendung eine neue Instanz erzeugt. Zusätzlich zu dieser Fabrik trägt sie einen Identifikator für Diagnosezwecke sowie eine Sortierreihenfolge, deren niedrigere Werte einen Eintrag weiter links in der Navigationsleiste platzieren. Dieser Unterschied zwischen Datensatz und Schnittstelle ist nicht beliebig, sondern ergibt sich unmittelbar aus den Eigenschaften des zugrunde liegenden UI-Frameworks. Wo Beschreibungsobjekte ausreichen, kommen Records zum Einsatz; wo eine Erzeugung pro Anwendungsfall erforderlich ist, tritt eine Schnittstelle an ihre Stelle.
public interface NavbarContribution {
String id();
Supplier<Component> componentFactory();
default int order() {
return 1000;
}
}
Für die Beobachtung des Zählers ist ein vierter, ergänzender Vertrag vorgesehen. Die Schnittstelle CounterEventListener beschreibt einen einzelnen Zuhörer, der über jede Wertänderung benachrichtigt wird. Da nicht jede Erweiterung notwendigerweise an diesen Ereignissen interessiert ist, wurde die Möglichkeit, Zuhörer beizutragen, nicht in FeatureContribution selbst aufgenommen, sondern in eine spezialisierte Untervariante CounterEventFeature ausgelagert, die die Basisschnittstelle um die Methode counterEventListeners erweitert. Eine Erweiterung, die ausschließlich neue Sichten beisteuern möchte, implementiert weiterhin nur FeatureContribution. Eine Erweiterung, die zusätzlich auf Zählerereignisse reagieren möchte, implementiert CounterEventFeature und liefert dort eine Liste ihrer Zuhörer mit. Diese Aufteilung folgt dem Grundsatz, dass eine Schnittstelle nur jene Methoden vorschreiben sollte, die für ihren Zweck unverzichtbar sind, und keine ungenutzten Anhängsel mit sich herumschleppen sollte.
public interface CounterEventListener {
void onCounterChanged(CounterChangedEvent event);
}
public interface CounterEventFeature extends FeatureContribution {
List<CounterEventListener> counterEventListeners();
}
Ebenso aufschlussreich wie das, was die API erlaubt, ist das, was sie nicht erlaubt. Eine Erweiterung kann keine bestehende Route ersetzen, kann nicht die Implementierung des CounterService austauschen, kann sich nicht in das Hauptlayout einklinken und kann den Anwendungskontext nicht umbauen. Sie kann ausschließlich neue Sichten registrieren, neue Menüeinträge hinzufügen, ergänzende Elemente in der Navigationsleiste platzieren und neue Zuhörer an das Zählerereignis anhängen. Die Erweiterung wirkt additiv, niemals invasiv. Diese Beschränkung mag streng wirken, ist jedoch ein bewusstes architektonisches Tauschmittel. Was der API an Mächtigkeit fehlt, gewinnt sie an Stabilität. Der Kern lässt sich unabhängig fortentwickeln, ohne dass eine Erweiterung mit eigener Lebenszeit zerbrechen könnte, solange die wenigen hier vereinbarten Strukturen unverändert bleiben.

Diagramm 2: Klassendiagramm des Erweiterungs-API
Auffindung über den ServiceLoader und die FeatureRegistry
Mit der im vorigen Kapitel beschriebenen API ist zunächst die Form festgelegt, in die sich Erweiterungen einbringen dürfen. Offen bleibt die Frage, auf welchem Weg der Kern zur Laufzeit von der Existenz dieser Erweiterungen erfährt. Eine Erweiterung kann sich nicht aktiv in den Kern einbringen; vielmehr muss sie passiv auffindbar sein. Damit ist ein Auffindemechanismus gefordert, der zwei zunächst widersprüchlich erscheinende Eigenschaften vereint. Er muss neue Beiträge automatisch auffinden, und er muss zugleich vollständig transparent darüber Auskunft geben, woher diese Beiträge stammen. Der ServiceLoader der Java-Standardbibliothek erfüllt beide Anforderungen und wird in diesem Vorhaben als alleiniger Auffindemechanismus verwendet.
Der ServiceLoader ist seit Java 6 Bestandteil des JDK und kennt keinerlei Magie. Jedes Modul, das einen Beitrag liefern möchte, legt eine Textdatei in seinem Klassenpfad ab, deren Name dem voll qualifizierten Namen der Dienstschnittstelle entspricht und deren Inhalt zeilenweise die voll qualifizierten Namen der Implementierungsklassen aufzählt. Zur Laufzeit fragt der ServiceLoader den Klassenpfad nach allen Vorkommen dieser Datei ab, lädt die genannten Klassen, ruft den parameterlosen Standardkonstruktor auf und liefert die entstandenen Objekte zurück. Mehr geschieht nicht. Es findet weder ein reflexionsbasierter Klassenpfad-Scan noch eine Annotationsauswertung statt, und es wird auch kein verborgener Anwendungskontext aufgebaut.
Im vorliegenden Beitrag trägt die einschlägige Datei in beiden Modulen den Namen META-INF/services/com.svenruppert.opencore.counter.extension.FeatureContribution. Ihr Inhalt variiert je nach Modul. Im Community-Projekt verweist sie auf com.svenruppert.opencore.counter.ui.core.CoreFeatureContribution, im Enterprise-Projekt auf com.svenruppert.opencore.counter.enterprise.EnterpriseFeatureContribution. Die bloße Anwesenheit der jeweiligen JAR-Datei im Klassenpfad genügt, um den darin enthaltenen Beitrag zu aktivieren. Wird die Enterprise-JAR entfernt, läuft die Anwendung als reine Community-Edition; wird sie hinzugefügt, treten ihre zusätzlichen Sichten, Menüeinträge, Navigationsleisten-Ergänzungen und Ereigniszuhörer in Erscheinung. Der Vorgang ist deklarativ und vollständig im Klassenpfad einer Anwendung dokumentiert.
Die Aufgabe, das Ergebnis des ServiceLoader in eine für den Kern brauchbare Form zu bringen, übernimmt die Klasse FeatureRegistry. Sie ist als unveränderliches Komponentenobjekt konzipiert, das ihre gesamte Arbeit im Konstruktor erledigt. Der Aufruf des ServiceLoader selbst ist in eine private statische Hilfsmethode ausgelagert, deren einziger Zweck darin besteht, das vom ServiceLoader gelieferte Iterable in eine konventionelle Liste zu überführen.
private static List<FeatureContribution> loadViaServiceLoader() {
List<FeatureContribution> result = new ArrayList<>();
for (FeatureContribution contribution :
ServiceLoader.load(FeatureContribution.class)) {
result.add(contribution);
}
return result;
}

Diagramm 3: Ablauf der Beitragsauffindung beim Start
Die geladenen Beiträge werden anschließend gemäß ihrer Sortierreihenfolge sortiert. Im Anschluss daran werden Routen, Menüeinträge, Ergänzungen der Navigationsleisten und Ereigniszuhörer jeweils in eigene, ebenfalls unveränderliche Listen zusammengeführt. Sämtliche Prüfungen, deren Verletzung den Start verhindern muss, finden ebenfalls hier statt. Damit gilt: Lässt sich eine FeatureRegistry, ist die gesammelte Konfiguration in sich stimmig, und das nachgelagerte System kann auf weitere Validierungen verzichten.
Die Konfliktbehandlung folgt einer klaren Regel. Doppelte Routenpfade führen zu einer IllegalStateException, ebenso doppelte Menüeinträge, wobei für Letztere die Kombination aus Etikett und Pfad als Schlüssel dient. Diese Asymmetrie ist beabsichtigt, denn zwei verschiedene Menüeinträge dürfen durchaus auf denselben Pfad zeigen, solange sie sich im angezeigten Etikett unterscheiden. Die Ausnahmen enthalten jeweils eine aussagekräftige Meldung, die sowohl den betroffenen Beitrag als auch das verursachende Feature benennt. Dass die Anwendung beim Start mit einer aussagekräftigen Fehlermeldung abbricht, anstatt mit halb registrierten Routen weiterzulaufen, ist die hier gewählte Variante des Grundsatzes „fail fast”.
List<RouteContribution> collectedRoutes = new ArrayList<>();
Set<String> seenRoutePaths = new HashSet<>();
for (FeatureContribution feature : this.features) {
for (RouteContribution route : feature.routes()) {
if (!seenRoutePaths.add(route.path())) {
throw new IllegalStateException(
"Duplicate route path '" + route.path()
+ "' contributed by feature '" + feature.id() + "'");
}
collectedRoutes.add(route);
}
}
Eine zweite Beobachtung lohnt sich beim Einsammeln der Ereigniszuhörer. Da nur jene Beiträge an dieser Stelle einen Eintrag liefern können, die das Sub-Interface CounterEventFeature implementieren, prüft die Registry pro Beitrag, ob dieser ein CounterEventFeature ist, und ruft nur in diesem Fall die zugehörige Methode auf. Das Pattern Matching mit macht diese Prüfung zu einem einzeiligen Vorgang.
List<CounterEventListener> collectedListeners = new ArrayList<>();
for (FeatureContribution feature : this.features) {
if (feature instanceof CounterEventFeature eventFeature) {
collectedListeners.addAll(eventFeature.counterEventListeners());
}
}
Neben dem von der Anwendung verwendeten parameterlosen Konstruktor bietet die Registry einen zweiten, parametrisierten Konstruktor, der eine vorgegebene Liste von Beiträgen entgegennimmt. Dieser dient ausschließlich der Testbarkeit. Er erlaubt es, die Sortier- und Konfliktlogik gegen handgesetzte Konstellationen zu prüfen, ohne den ServiceLoader selbst durch Klassenpfad-Manipulationen täuschen zu müssen.
Aus der Vielzahl möglicher Auffindemechanismen wurde der ServiceLoader aus mehreren Gründen bevorzugt. Ein reflexionsbasierter Klassenpfad-Scan, wie ihn Bibliotheken wie Reflections oder ClassGraph anbieten, kann sehr ähnliche Ergebnisse liefern, fügt jedoch eine zusätzliche Abhängigkeit hinzu und überlässt es der Bibliothek, zu entscheiden, was als Beitrag gezählt wird. Annotationsbasierte Mechanismen, wie sie Spring oder CDI nutzen, verlagern die Registrierung von einer expliziten Textdatei auf eine Annotation im Quellcode. Was kürzer wirkt, geht jedoch mit einer Bindung an die jeweilige Plattform einher und macht die Frage, welche Beiträge in einem konkreten Klassenpfad enthalten sind, ohne Werkzeuge nicht ohne Weiteres beantwortbar. Der ServiceLoader hingegen erfordert keine zusätzlichen Abhängigkeiten, ist in jedem JDK enthalten und führt jede Registrierung sichtbar in einer Datei zusammen. Genau diese Sichtbarkeit ist im Open-Core-Kontext besonders wertvoll. Wer wissen möchte, welche Module sich an einem laufenden System beteiligen, findet die Antwort durch einen Blick in die ausgepackten JAR-Dateien, ohne den Quellcode des Kerns konsultieren zu müssen.
Vaadin-Integration: AppLayout und dynamische Routen
Mit dem im vorigen Kapitel beschriebenen Mechanismus stehen alle Beiträge bereits in geordneter, geprüfter Form bereit, sobald die FeatureRegistry ihren Konstruktor verlassen hat. Was bislang fehlt, ist die Übersetzung dieser Beiträge in Strukturen, die Vaadin tatsächlich anzeigt und auswertet. Diese Aufgabe gliedert sich in zwei Komponenten. Die erste ist das Hauptlayout MainLayout, das die Menüeinträge in den Navigationsbereich sowie die Ergänzungen der Navigationsleiste in den oberen Bereich einbringt. Die zweite ist der OpenCoreRouteInitializer, der die gesammelten Routen zum richtigen Zeitpunkt im Vaadin-Routensystem hinterlegt. Beide Komponenten bilden gemeinsam die einzige Vaadin-spezifische Schicht des Open-Core-Aufbaus.
Das MainLayout erbt von AppLayout und ist eine vollkommen gewöhnliche Vaadin-Komponente, allerdings mit einer Besonderheit. Es trägt die Annotation @Layout("/"). Diese Annotation ist nötig, weil Vaadin für die Erstellung des produktiven Frontend-Bundles auf eine statische Analyse des Quellcodes angewiesen ist. Der Bundle-Scanner durchsucht den Bytecode nach @Route- und @Layout-Annotationen, deren Referenzen, und ermittelt daraus die Menge der Vaadin-Komponenten, die in das Bundle aufgenommen werden müssen. Da im vorliegenden Aufbau kein Sicht-Layout eine @Route-Annotation trägt, wäre das MainLayout ohne @Layout für den Bundle-Scanner unsichtbar, was zur Folge hätte, dass die im Layout verwendeten Komponenten und Themes nicht im produktiven Bundle landen. Die Annotation hat damit eine rein technische, im weiteren Sinne build-bezogene Funktion und keine Auswirkung auf die Erweiterungsmechanik.
Im Konstruktor des Layouts werden die Navigationsleiste und der Drawer eingerichtet. Der Drawer enthält eine SideNav-Komponente, die mit den Menüeinträgen aus der Registry befüllt wird. Da diese bereits beim Bau der Registry nach Sortierreihenfolge geordnet wurden, kann das Layout schlicht über die Liste iterieren und ist von Sortierfragen befreit. Jeder Eintrag wird in ein SideNavItem umgesetzt, dessen Pfadangabe um einen führenden Schrägstrich ergänzt wird, was der Erwartung des Vaadin-Routers entspricht.
Die Navigationsleiste wird ebenfalls aus der Registry gespeist. Liegen Navigationsleisten-Ergänzungen vor, werden sie in einem eigenen HorizontalLayout zusammengefasst und rechts neben dem Titel platziert. Genau an dieser Stelle bewährt sich die im vierten Kapitel beschriebene Entscheidung, NavbarContribution als Schnittstelle mit einer Supplier<Component>-Fabrik zu modellieren. Das Layout ruft die Fabrik auf, erhält eine frische Komponenteninstanz und kann diese ohne weitere Vorkehrungen einhängen.
@Layout("/")
public class MainLayout extends AppLayout {
public MainLayout() {
addToNavbar(createHeader());
addToDrawer(createDrawer());
}
private HorizontalLayout createHeader() {
H1 title = new H1("OpenCore Counter");
HorizontalLayout header =
new HorizontalLayout(new DrawerToggle(), title);
header.setAlignItems(FlexComponent.Alignment.CENTER);
header.setWidthFull();
var navbarItems =
Application.context().featureRegistry().navbarItems();
if (!navbarItems.isEmpty()) {
HorizontalLayout extras = new HorizontalLayout();
extras.getStyle().set("margin-left", "auto");
for (NavbarContribution contribution : navbarItems) {
extras.add(contribution.componentFactory().get());
}
header.add(extras);
header.setFlexGrow(1, extras);
}
return header;
}
private VerticalLayout createDrawer() {
SideNav nav = new SideNav();
for (MenuContribution item
: Application.context().featureRegistry().menuItems()) {
nav.addItem(new SideNavItem(item.label(), "/" + item.path()));
}
VerticalLayout layout = new VerticalLayout(nav);
layout.setPadding(false);
layout.setSpacing(false);
return layout;
}
}
Bemerkenswert ist, was das Layout nicht enthält. Es trägt keine einzige Importanweisung auf eine Klasse aus dem Enterprise-Modul, kennt weder HistoryView noch AuditLogView und führt keinerlei Fallunterscheidungen anhand vorhandener oder fehlender Erweiterungen vor. Die einzige Quelle, aus der das Layout schöpft, ist die FeatureRegistry. Was diese liefert, erscheint im Drawer und in der Navigationsleiste; was sie nicht liefert, ist nicht vorhanden. Diese kompromisslose Anbindung an die Registry ist es, die das Layout für beide Editionen identisch funktionieren lässt.
Den zweiten Vaadin-Integrationspunkt bildet der OpenCoreRouteInitializer. Er implementiert die Vaadin-eigene Schnittstelle VaadinServiceInitListener, deren serviceInit-Methode genau einmal beim Hochfahren des Vaadin-Dienstes aufgerufen wird. Genau dieser Zeitpunkt ist geeignet, um die in der FeatureRegistry gesammelten Routen in die laufende Vaadin-Konfiguration zu übertragen. Der Initializer holt sich die für den Anwendungsbereich zuständige RouteConfiguration, durchläuft die Liste der Routenbeiträge und registriert jeden Pfad samt zugehöriger Sichtklasse beim Vaadin-Routenregister. Als Layout für jede dieser Routen wird MainLayout festgelegt, wodurch alle dynamisch registrierten Sichten innerhalb desselben Rahmens erscheinen.
public class OpenCoreRouteInitializer
implements VaadinServiceInitListener {
@Override
public void serviceInit(ServiceInitEvent event) {
RouteConfiguration routeConfiguration =
RouteConfiguration.forApplicationScope();
RouteRegistry registry = routeConfiguration.getHandledRegistry();
registry.update(() -> {
for (RouteContribution route :
Application.context().featureRegistry().routes()) {
Class<? extends Component> viewClass = route.viewClass();
if (!routeConfiguration.isPathAvailable(route.path())) {
routeConfiguration.setRoute(
route.path(), viewClass, MainLayout.class);
}
}
});
}
}

Diagramm 4: Vaadin-Integration in zwei Lebenszyklusphasen
Die Bekanntgabe des Initializers an Vaadin folgt erneut dem ServiceLoader-Muster, hier allerdings entlang einer von Vaadin selbst definierten Dienstschnittstelle. Die Datei META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener enthält eine einzige Zeile mit dem voll qualifizierten Namen des Initializers. Damit ist die Mechanik geschlossen. Die anwendungseigenen Beiträge werden über die selbstdefinierte FeatureContribution-Dienstschnittstelle entdeckt, die Vaadin-seitige Verarbeitung dieser Beiträge wird über die Vaadin-eigene VaadinServiceInitListener-Dienstschnittstelle eingehängt. Beide Schnittstellen sind über denselben, im JDK enthaltenen Mechanismus auffindbar gemacht.
Der bewusste Verzicht auf @Route-Annotationen sind die unmittelbare Konsequenz dieser Architektur. Eine @Route-Annotation würde eine Sicht zur Compile-Zeit fest mit einem Pfad verbinden und sie damit der dynamischen Zuteilung entziehen. Die Erweiterungsmechanik wäre auf jene Sichten beschränkt, die sich auf einen festen Pfad verständigt haben, wodurch die Trennung zwischen Kern und Erweiterung unterlaufen würde. Im vorliegenden Aufbau hingegen entscheidet allein die RouteContribution darüber, über welchen Pfad eine Sicht erreichbar ist. Ein Beitragender kann einen Pfad ändern, ohne die Sicht selbst zu berühren, und der Kern bleibt frei von Wissen über die Pfade einzelner Beiträge. Die Annotation @Layout, die das MainLayout als einzige Ausnahme enthält, ist davon nicht betroffen, da sie keinen Pfad festlegt, sondern lediglich dem Bundle-Scanner ihre Existenz mitteilt.
Übergang zum zweiten Teil
Damit endet der erste Teil dieses Beitrags. Mit dem Erweiterungs-API, der FeatureRegistry als zentrale Anlaufstelle für die zur Laufzeit aufgefundenen Beiträge und die Vaadin-seitige Übersetzung in Routen, Menüeinträge und Ergänzungen der Navigationsleisten ist die Mechanik der Open-Core-Anwendung vollständig beschrieben.
Was bislang noch aussteht, ist ihre konkrete Anwendung im Beispielsystem, also der Anschluss der Community-Edition und der Enterprise-Erweiterung an diese Mechanik, sowie die Frage, wie sich das Ganze über einen eingebetteten Servlet-Container betreiben und über Tests dauerhaft gegen Grenzverletzungen absichern lässt. Diese praktischen Aspekte sind Gegenstand des zweiten Teils.