Editions, runtime, and quality assurance.
All source code presented here is published on GitHub and available at https://3g3.eu/naityh.
Recap of part one
The first part of this article developed the abstract mechanics of the open-core application. From two independent Maven projects with strictly asymmetric dependency direction, a deliberately slim extension API with the contribution types RouteContribution, MenuContribution, NavbarContribution, and CounterEventListener, a FeatureRegistry that discovers its contributions via the Java standard library’s ServiceLoader, and a Vaadin integration consisting of MainLayout and OpenCoreRouteInitializer, an apparatus is now in place that can accept arbitrary contributions in ordered form, without the core needing to know their identity.
What follows now is the concrete application of this mechanism in the community edition and the enterprise extension, then the runtime via an embedded Jetty, and finally the tests that permanently secure the architectural boundaries. The chapters retain their continuous numbering from the first part, so that cross-references between the two parts read without further translation.
7. The community edition is the first contribution instance
With all three building blocks developed earlier, the extension API, the FeatureRegistry, and the Vaadin integration, the machinery for contributions is fully in place but not yet in use. An application started in this form would be operable but would offer neither routes nor menu entries. The first contribution comes from the community edition itself. It is not a privileged part of the core that gains visibility through special wiring, but a perfectly ordinary contribution instance that uses the same mechanics that will later also be available to the enterprise extension. This symmetry is one of the central statements of the whole design.
At the centre is the class CoreFeatureContribution. It implements FeatureContribution, carries the identifier community.core, and announces two routes and two menu entries. The first entry points to CounterView and is bound to the empty path, making it the application’s default view. The second point AboutView under the path about. The sort orders for the menu entries are chosen so that CounterView it appears at the top with the value 100 and AboutView, with the value 900, occupies the end of the list, leaving room for the middle ranges, which in the enterprise case, slot in there with values between 300 and 500.
public final class CoreFeatureContribution
implements FeatureContribution {
public static final String FEATURE_ID = "community.core";
@Override
public String id() {
return FEATURE_ID;
}
@Override
public List<RouteContribution> routes() {
return List.of(
new RouteContribution("", CounterView.class),
new RouteContribution("about", AboutView.class));
}
@Override
public List<MenuContribution> menuItems() {
return List.of(
new MenuContribution("Counter", "", 100, "vaadin:plus"),
new MenuContribution("About", "about", 900,
"vaadin:info-circle"));
}
@Override
public int order() {
return 100;
}
}
This class is registered with the ServiceLoader via exactly the same file that also serves as the entry point for the enterprise extension. In the community project, it resides under src/main/resources/META-INF/services/com.svenruppert.opencore.counter.extension.FeatureContribution and contains a single line with the fully qualified name of the contribution class. The fact that the community module announces itself via the same route that is also open to external extensions is more than a cosmetic consequence. It is the practical proof that the API is just as suitable for contributions from the codebase itself as for those from external modules. If the mechanism were to prove inadequate here, it would also be unusable for extensions; since it works for the community edition, it works for any further contributor.
The two referenced views are deliberately kept simple. CounterView obtains the central counter service via Application.context().counterService() and presents its current value in a Span component. Three buttons trigger the familiar operations and then cause the value to be redisplayed. The view carries no @Route annotation, since its binding to a path runs exclusively through the RouteContribution in CoreFeatureContribution. Each button additionally receives a fixed identifier via setId, which makes the view addressable for the browserless tests presented later.
public class CounterView extends VerticalLayout {
public static final String ID_VALUE_LABEL = "counter-value";
public static final String ID_INCREMENT_BUTTON = "btn-increment";
public static final String ID_DECREMENT_BUTTON = "btn-decrement";
public static final String ID_RESET_BUTTON = "btn-reset";
private final CounterService counterService;
private final Span valueLabel;
public CounterView() {
this.counterService = Application.context().counterService();
add(new H2("Counter"));
valueLabel = new Span(String.valueOf(counterService.value()));
valueLabel.setId(ID_VALUE_LABEL);
add(valueLabel);
Button increment = new Button("+1", e -> {
counterService.increment();
refresh();
});
increment.setId(ID_INCREMENT_BUTTON);
Button decrement = new Button("-1", e -> {
counterService.decrement();
refresh();
});
decrement.setId(ID_DECREMENT_BUTTON);
Button reset = new Button("Reset", e -> {
counterService.reset();
refresh();
});
reset.setId(ID_RESET_BUTTON);
add(new HorizontalLayout(increment, decrement, reset));
}
private void refresh() {
valueLabel.setText(String.valueOf(counterService.value()));
}
}
The AboutView is even more restrained. It consists of a heading and three paragraphs that briefly explain that this is the OSS edition, that further features appear as soon as the enterprise JAR is on the classpath, and that all features are loaded via the ServiceLoader. The view thus takes on a small pedagogical function for anyone running the application for the first time.
With CoreFeatureContribution, its entry in the service file, and the two views, the community edition is complete. It runs without further intervention and delivers an application with two views and two menu entries. What happens later when the enterprise extension is added is, from the core’s point of view, nothing different. Another implementation of FeatureContribution is found, it brings another list of routes, menu entries, navbar additions, and event listeners, and the mechanism already described integrates both into a shared configuration. From the core’s perspective, it makes no difference whether one contribution comes from its own module and the other from a third party.
8. The enterprise extension
With the community edition, the application stands in its first operable form. What still remains is the actual open-core value: a second contribution instance that adds features to the same system without requiring a single line of code change to the core. This task is taken on by the enterprise extension. It delivers three additional views, three additional menu entries, an element for the navigation bar, and two event listeners that record each change to the counter in their own stores. What is remarkable is not the scope of these contributions but the fact that they are announced exclusively via the interfaces described in chapter four.
The central class is called EnterpriseFeatureContribution and differs from CoreFeatureContribution initially only in one respect. It implements not FeatureContribution , but its sub-variant CounterEventFeature. This choice is the only structural consequence of the enterprise extension being interested in the counter events and therefore having listeners to contribute. All other methods override the same contracts that the community variant also served. With a sort order of 500 slots, the enterprise contribution falls between the community edition, with its order of 100, and the default value of 1000.
public final class EnterpriseFeatureContribution
implements CounterEventFeature {
public static final String FEATURE_ID = "enterprise.counter";
public static final String NAVBAR_BADGE_ID = "enterprise.edition.badge";
@Override
public String id() {
return FEATURE_ID;
}
@Override
public List<RouteContribution> routes() {
return List.of(
new RouteContribution("history", HistoryView.class),
new RouteContribution("audit-log", AuditLogView.class),
new RouteContribution("export", ExportView.class));
}
@Override
public List<MenuContribution> menuItems() {
return List.of(
new MenuContribution("History", "history", 300, "vaadin:clock"),
new MenuContribution("Audit Log", "audit-log", 400, "vaadin:list"),
new MenuContribution("Export", "export", 500, "vaadin:download"));
}
@Override
public List<CounterEventListener> counterEventListeners() {
return List.of(
new HistoryCounterEventListener(),
new AuditLogCounterEventListener());
}
@Override
public List<NavbarContribution> navbarItems() {
return List.of(new NavbarContribution() {
@Override
public String id() {
return NAVBAR_BADGE_ID;
}
@Override
public Supplier<Component> componentFactory() {
return EnterpriseEditionBadge::new;
}
@Override
public int order() {
return 100;
}
});
}
@Override
public int order() {
return 500;
}
}
The NavbarContribution is expressed in the source code as an anonymous class, which underlines the locality of the contribution declaration. A single contribution is defined on the spot, without its own file or a separate class. The factory provides a fresh component instance on each call via the method reference EnterpriseEditionBadge::new, exactly as the contract form described in chapter four prescribes. The sort order of 100 ensures that the edition badge appears to the left of any future additions to the navbar.
The two event listeners follow a common pattern. They take a CounterChangedEvent, shape it into their own store entry, and place it in a module-internal store. The HistoryCounterEventListener produces a HistoryEntry that mirrors the event’s components unchanged. Both listeners accept an external store in a second constructor; this constructor serves the tests and allows the singleton-like behaviour of the preferred constructor to be bypassed.
public final class HistoryCounterEventListener
implements CounterEventListener {
private final HistoryStore store;
public HistoryCounterEventListener() {
this(HistoryStore.getInstance());
}
public HistoryCounterEventListener(HistoryStore store) {
this.store = store;
}
@Override
public void onCounterChanged(CounterChangedEvent event) {
store.add(new HistoryEntry(
event.timestamp(),
event.oldValue(),
event.newValue(),
event.action()));
}
}
The AuditLogCounterEventListener is structurally identical in its construction but translates the event into a textual entry containing a readable description of the change. The entry class AuditEntry carries a timestamp, an event type, and a message that, in the case of a counter change, names the old value, the new value, and the underlying action.
public final class AuditLogCounterEventListener
implements CounterEventListener {
public static final String EVENT_TYPE = "COUNTER_CHANGED";
private final AuditLogStore store;
public AuditLogCounterEventListener() {
this(AuditLogStore.getInstance());
}
public AuditLogCounterEventListener(AuditLogStore store) {
this.store = store;
}
@Override
public void onCounterChanged(CounterChangedEvent event) {
String message = "Counter changed from "
+ event.oldValue() + " to " + event.newValue()
+ " by action " + event.action().name();
store.add(new AuditEntry(event.timestamp(), EVENT_TYPE, message));
}
}
The two stores HistoryStore and AuditLogStore are likewise identical in form. Each consists of a CopyOnWriteArrayList that allows concurrent access without explicit synchronisation, a private static singleton, and a few accessor methods for adding, retrieving, clearing, and counting entries. The choice of a simple, concurrency-safe list is sufficient for the trivial domain of this article. A production application would replace it at this point with a proper persistence layer.
public final class HistoryStore {
private static final HistoryStore INSTANCE = new HistoryStore();
private final List<HistoryEntry> entries = new CopyOnWriteArrayList<>();
public static HistoryStore getInstance() {
return INSTANCE;
}
public void add(HistoryEntry entry) {
entries.add(entry);
}
public List<HistoryEntry> entries() {
return List.copyOf(entries);
}
public void clear() {
entries.clear();
}
public int size() {
return entries.size();
}
}
The stores and the associated entry types, all modelled as records, reside exclusively in the enterprise module. The community knows neither HistoryStore nor AuditLogStore, neither HistoryEntry nor AuditEntry, and it need not know these classes. The sole connection between core and extension runs through the CounterEventListener interface, which is defined in the community module and implemented in the enterprise module. The community module publishes events to a list of listeners whose concrete classes remain hidden from it. It is precisely this hiddenness that carries the entire venture architecturally.

Diagram 5: Event path from click to stores (enterprise mode)
The three Vaadin views of the enterprise extension, HistoryView, AuditLogView, and ExportView, follow the same pattern as the community views. Each accesses its own store, shapes its contents into a presentation, and reacts to ordinary Vaadin component events. Since the views contribute no new architectural insight, they are not treated in detail here; the full source code is available for inspection in the repository.
With the registration of EnterpriseFeatureContribution in the service file META-INF/services/com.svenruppert.opencore.counter.extension.FeatureContribution of the enterprise module, the extension is connected to the same machinery that previously processed the community edition. On every start, with both modules on the classpath, the FeatureRegistry finds two contributions, sorts them, collects their routes, menu entries, navbar additions, and event listeners, and hands the overall configuration on to the Vaadin integration. The core itself has changed nothing, checked nothing, and known nothing. The extension is purely additive and takes place entirely outside the core’s own sources.
9. Runtime with embedded Jetty
The application needs a servlet container, since Vaadin Flow relies on VaadinServlet to deliver its frontend. Instead of an external application server, this design uses Jetty as a library within the application. The application itself thus remains an ordinary Java program with a main method; the servlet container is merely one of its dependencies.
The runtime is encapsulated in two small classes. The first, CounterServlet, is a plain subclass of VaadinServlet with no logic of its own. Its existence is necessary because Vaadin recognises the servlet in the running web application by its type; a dedicated type also makes it possible to introduce application-specific adjustments later on without modifying Vaadin’s own servlet.
public class CounterServlet extends VaadinServlet {
}
The second class, CounterApplicationLauncher, contains the main method and is responsible for programmatically setting up Jetty. It creates a Server on the chosen port, attaches a WebAppContext as the root, and registers CounterServlet in it under the path pattern /*. In addition, Jetty’s AnnotationConfiguration is enabled so that Vaadin’s bytecode-based discovery of configuration and component classes works as if the application were running in a fully fledged servlet container. The configuration of the classpath scan is done via the MetaInfConfiguration attributes, which here are opened to all JAR files in order to include both the community and the enterprise JAR.
static Server startServer(int port) throws Exception {
ensureProductionTokenFile();
Server server = new Server(port);
WebAppContext webapp = new WebAppContext();
webapp.setContextPath("/");
ResourceFactory rf = ResourceFactory.of(webapp);
Resource base = rf.newClassLoaderResource("META-INF/resources", true);
webapp.setBaseResource(base);
webapp.setConfigurationDiscovered(true);
webapp.addConfiguration(new AnnotationConfiguration());
webapp.setAttribute(
MetaInfConfiguration.CONTAINER_JAR_PATTERN, ".*\\.jar$");
webapp.setAttribute(
MetaInfConfiguration.WEBINF_JAR_PATTERN, ".*\\.jar$");
webapp.setParentLoaderPriority(true);
ServletHolder holder = new ServletHolder(new CounterServlet());
holder.setInitOrder(1);
holder.setAsyncSupported(true);
holder.setInitParameter("productionMode", "true");
webapp.addServlet(holder, "/*");
server.setHandler(webapp);
server.start();
return server;
}
The method ensureProductionTokenFile creates a small Vaadin-specific configuration file if it is not yet present on the classpath. Vaadin uses this file to distinguish between production and development modes. A more detailed treatment of this Vaadin detail would go beyond the architectural focus of this article; what is decisive is that the application is thereby also operable without the Vaadin Maven plugin in a typical development environment.
The decisive point from the architectural perspective lies not in the configuration of Jetty but in the distinction from a Jakarta EE platform. The Servlet API is undoubtedly used here, but exclusively as a library. The application uses neither CDI nor JPA, neither EJB nor JAX-RS, and it is not dependent on the presence of an application server. It can be started as an ordinary Java application via java -cp … CounterApplicationLauncher. The only peculiarity compared to a purely console-bound program is the fact that the main method does not terminate, but waits for the server to shut down via server.join().
The same launcher is also used in the enterprise edition. The enterprise module brings no entry point of its own but instead starts CounterApplicationLauncher from the community module via the Maven exec plugin. Since the enterprise JAR is on the same classpath, its META-INF/services file is also found by the ServiceLoader, and the enterprise contributions are loaded along with the community contributions. Which edition is active at runtime is therefore decided not by the launcher but by the classpath alone.
10. Architectural checks and browserless tests
An architecture visible in the source code can also be verified in the source code. The open-core model, as developed in the previous chapters, rests essentially on two properties. First, the community module must contain no references to the enterprise module, and second, the application must display the menu contents and views appropriate to the edition in both operating modes, that is, with and without the enterprise JAR on the classpath. Both properties can be formulated as tests and are checked on every build. Other tests, for example, on the domain, on the CounterService, or on the event listeners, are of course also present, but recede in their architectural significance behind the two named.
The source-based architectural check goes by the name CommunityDoesNotReferenceEnterpriseTest and is deliberately simple in its construction. It walks the source tree under src/main/java of the community module, reads each Java file as a string, and looks in it for a small list of forbidden tokens. Any hit found is collected and reported at the end as a violation. The tokens are chosen so as to cover every conceivable route by which enterprise classes might appear in the community source, that is, the package name .counter.enterprise as well as the class names EnterpriseFeatureContribution, HistoryView, AuditLogView, and ExportView. A simple text search is entirely sufficient here, since an import statement or a fully qualified class name will in either case contain precisely these character sequences.
static final List<String> FORBIDDEN_TOKENS = List.of(
".counter.enterprise",
"EnterpriseFeatureContribution",
"HistoryView",
"AuditLogView",
"ExportView");
@Test
@DisplayName("community sources contain no reference "
+ "to enterprise package or types")
void communitySourcesDoNotReferenceEnterprise() throws IOException {
Path sourceRoot = Path.of("src", "main", "java");
assertTrue(Files.isDirectory(sourceRoot),
"Expected community source root at " + sourceRoot.toAbsolutePath());
List<String> violations = new ArrayList<>();
try (Stream<Path> files = Files.walk(sourceRoot)) {
files
.filter(p -> p.toString().endsWith(".java"))
.forEach(p -> scanFile(p, violations));
}
assertTrue(violations.isEmpty(),
"Community sources must not reference enterprise:\n - "
+ String.join("\n - ", violations));
}
This form of check is deliberately source-based and not bytecode-based. Tools such as ArchUnit could make the same statement about the compiled classes and would, on top of that, judge more accurately in semantic terms. For the present project, however, a text search suffices, since the ratio of effort to insight is particularly favourable here. If the community module contains no enterprise names in letter form, it cannot contain them in its bytecode either. The test is therefore a very direct translation of the originally purely organisational statement “the community does not know the enterprise” into executable code. If it fails, the build fails, and the violation of the open-core boundary becomes immediately visible.
The second class of tests concerns the actual visibility of the extensions in the user interface. Since Vaadin resists starting up without a servlet container, one might be tempted to fall back here on a full integration test with a browser. This is precisely what is avoided. Instead, the library browserless-test-junit6 from Vaadin is used, which builds up a full Vaadin component tree in memory but requires no browser. Tests of this kind run in a few hundred milliseconds and can therefore be executed as ordinary unit tests on every build.
In the community module, the class MainLayoutBrowserlessTest verifies that the navigation bar contains exactly the two entries Counter and About and that the entries History, Audit Log, and Export are expressly absent. The view is invoked via navigate("", CounterView.class), the MainLayout is extracted from the active router target chain, and its SideNav is located recursively. The displayed labels are then checked against the expected list.
@Test
@DisplayName("community side-nav contains Counter and About "
+ "but no enterprise entries")
void sideNavContainsOnlyCommunityEntries() {
navigate("", CounterView.class);
MainLayout layout = UI.getCurrent().getInternals()
.getActiveRouterTargetsChain().stream()
.filter(c -> c instanceof MainLayout)
.map(c -> (MainLayout) c)
.findFirst()
.orElseThrow();
SideNav nav = findSideNav(layout);
List<String> labels = nav.getItems().stream()
.map(SideNavItem::getLabel).toList();
assertTrue(labels.contains("Counter"));
assertTrue(labels.contains("About"));
assertFalse(labels.contains("History"));
assertFalse(labels.contains("Audit Log"));
assertFalse(labels.contains("Export"));
}
The corresponding class EnterpriseMainLayoutBrowserlessTest in the enterprise module reads out the same MainLayout but expects a different constellation, namely, the full spectrum of Counter, History, Audit Log, Export, and About. In addition, a second test method verifies that the EnterpriseEditionBadge contributed by the enterprise module appears in the navigation bar and is rendered as a small, rounded badge. Both tests rely on the same layout class from the community module and merely make the same call in a different classpath configuration. With that, not only is it shown that the individual contributions are registered correctly, but also that the layout behaves entirely passively and delivers the right picture for both editions without any special handling.
A small technical peculiarity deserves a mention. Vaadin’s browserless tests limit their search helper $view(...) by default to the innermost route node, that is, CounterView for example. Content from the surrounding MainLayout, particularly anything in the drawer and in the navigation bar, is not reachable via this path. The test shown, therefore, takes the detour UI.getCurrent().getInternals().getActiveRouterTargetsChain() to filter the MainLayout out of this chain, and then traverses its child components by hand. This is not a shortcoming of the library, but a consequence of its deliberate scoping to the currently visible view area.
In addition to these architecturally most insightful tests, further checks exist that do not illuminate the venture’s open-core character but contribute to the completeness of the test set. These include unit tests for CounterState and CounterService, a test of the FeatureRegistry’s correct operation under hand-crafted constellations, a test of EnterpriseFeatureContribution based on its self-reported information, and tests of the two event listeners against their respective stores. They are all unremarkable architecturally and are not treated in detail here.
11. Conclusion
What this article has shown can be summarised in three statements. First, an open-core Vaadin application can be built entirely using the JDK, without Spring, without a Jakarta EE platform, and without its own plugin framework. Second, the separation between core and extension can be enforced architecturally rather than left to discipline in day-to-day development. Third, for this purpose, the ServiceLoader is not a fallback but, in many respects, a better choice than the seemingly more convenient annotation- or reflection-based alternatives, since it clearly documents the origin of every contribution in the respective JAR files.
What this article was deliberately not meant to show should be stated equally clearly. The example is not a business application but an architectural demonstration. The domain is kept trivial, storage is in memory, there is no user management or licence enforcement, and there is no multitenancy or session-bound state. A production application would provide additional mechanisms at each of these points, which are omitted here to avoid obscuring the actual topic. The singleton-like application context and the static stores of the enterprise extension are likewise to be read as didactic simplifications and not as recommendations for production use.
What, by contrast, ought to hold also in larger projects are the architectural guiding lines underlying the example. The exclusively additive effect of extensions, the build-enforced asymmetry of dependency directions, the uncompromising binding of the Vaadin integration to a central registry, and the source-based architectural check against boundary violations are all useful regardless of application size. They scale with the complexity of the domain without themselves becoming any more complicated.
Open-core is, in the end, less a technical question than a question of contract discipline. The mechanisms needed for it are, as this article has shown, of striking simplicity. The actual challenge lies not in implementing them but in resisting the temptation to open the extension API “just a little” over time, to introduce a convenient special arrangement, or to silently relax a boundary. A small, strictly observed API is, in the long run, superior to a powerful API that permits everything and therefore cannot reliably guarantee anything.