Open-Core with Core Java and Vaadin — Part 1

Sven Ruppert

Architecture, extension API, and Vaadin integration.


1. Introduction and motivation

The open-core model is often reduced in discussion to questions of licensing or commercial positioning. An open-source variant of a piece of software is complemented by a commercially distributed variant that brings additional features. Anyone who actually wants to implement such a separation in an existing codebase quickly discovers that the real difficulty lies neither in the licence text nor in the choice of repository, but in the code’s architecture.

The central question is which dependency directions are permissible and which must be prevented under all circumstances. The extension may access the core, while the core must know nothing of the extension’s existence. In theory, this is self-evident. In practice, however, one repeatedly observes that an inconspicuous helper class, a widespread configuration mechanism, or a convenient annotation reverses this direction over time without anyone noticing. With that, the topic of this article is outlined: how to build a Vaadin-based open-core product so that the separation is not merely documented but visible directly in the source code.

For the implementation, Spring and any Jakarta EE platform are deliberately avoided. This decision is not a judgment on the platforms in question, but a didactic one. Both tend to make the discovery and wiring of components so convenient that the question of where a class actually originates at runtime, and by what route it enters the application context, recedes into the background. Yet this question is central in the open-core context. Marking a component class with an annotation and having it picked up via a classpath scan yields a convenient solution, but loses visibility over which contributions come from which module. An explicit mechanism, in this case, the ServiceLoader from the Java standard library, forces the author of the core, just as much as the author of the extension, to specify the direction of the contribution.

One distinction should be addressed up front, since it regularly leads to misunderstandings. Vaadin Flow requires a servlet container, and the Servlet API is brought into the classpath in the example transitively via Vaadin and an embedded Jetty. The application itself is therefore not a Jakarta EE application. It uses neither CDI nor JPA, nor EJB nor JAX-RS, and it does not depend on an application server.

The subject domain is a deliberately trivial example, namely a counter with three operations and an associated event model. This triviality is intentional. It prevents the architectural argument from being obscured by domain detail and ensures that every step of the extension mechanism remains immediately comprehensible. What emerges in the end is not a realistic business product but a fully worked-through architectural demonstration of how an open-core Vaadin system can be built using only the JDK.

All source code presented here is published on GitHub and available at https://3g3.eu/naityh.

The article can be read independently of that. Anyone wishing to follow individual passages in their full context will find the unabridged state of the implementation there.


2. Architectural overview and project structure

The visibility of dependency directions, as called for in the previous chapter, begins not in the source code but in the project structure. The example application introduced here consists of two independent Maven projects, counter-community and counter-enterprise. Both are fully fledged artefacts that can be built individually, with their own POM, version progression, and release strategy. They are not connected through a shared hierarchy but merely through a simple library-level dependency.

The permitted dependency direction runs exclusively from the enterprise extension to the community edition. The enterprise extension takes the community as a library, uses its extension API, and accesses its layout classes. The community, on the other hand, knows nothing of the enterprise extension, neither at compile time nor at runtime. This asymmetry is the central architectural statement of the entire endeavour. It is not an agreement whose observance is secured by discipline alone, but a built-enforced property. Because the community project contains no reference to the enterprise project in its POM, the source code of the community edition simply cannot reference enterprise classes. What is not on the classpath at build time cannot be named in any import statement.

Diagram 1: Dependency direction of the Maven modules

This separation could in principle also be realised within a single multi-module Maven reactor. In the present article, however, the route via two mutually independent projects was deliberately chosen. The reason is not technical but organisational and didactic at the same time. In real open-core operation, the core and the commercial extension are usually developed in separate repositories, possibly even by different teams under different licensing terms. The structure chosen here mirrors this reality rather than concealing it behind a shared reactor file. The consequence is that the core can be released independently without bundling the extension with it, and that the extension is built against a specific version of the core, which is named like any other library dependency.

For day-to-day work in a development environment, however, the strict separation into two repositories is inconvenient. As long as the core and the extension are evolving together, it helps to be able to open, build, and browse both projects in a single session. For this reason, an aggregator POM resides in the root directory. It lists the two modules and enables IntelliJ IDEA, for instance, to open both projects in the same session.

This aggregator POM is explicitly not a shared parent POM. The two module POMs do not refer to it via <parent>, and they inherit neither dependency management nor plugin configuration from it. This distinction may seem pedantic at first, but it is deliberate. A shared parent POM would create a technical link between the two modules, contradicting the independence established earlier. The community project could no longer be built without the parent POM, and any divergence between the two projects in versioning strategy or plugin configuration would fail at this level of the hierarchy. The aggregator POM therefore remains a purely development-environment tool, with no significance for the build process of individual modules.


3. The domain: counter and events

The application’s domain task is one of selected triviality. An integer value can be incremented, decremented, and reset to zero. With that, the domain is exhaustively described in a single sentence. This reduction to the bare minimum is, as already hinted at in the introduction, intentional. Any elaboration on the domain would obscure rather than sharpen the article’s architectural argument, since it would draw attention to business rules whose existence is irrelevant to the mechanisms discussed here. The extension mechanics of an open-core application are to be considered independently of whether the extended system manages a counter, an invoicing process, or a utility network.

The state is held in a class CounterState, which manages an integer value and offers three methods: incrementing by 1, decrementing by 1, and resetting to 0. Each of these operations not only changes the internal state but also returns an event describing the change carried out. The three operations are defined in an enumeration, CounterAction, with the constants INCREMENT, DECREMENT, and RESET. This allows the cause of a value change to be named directly later, for example, in an audit log, without having to reconstruct the call path back to the state class.

The event itself is modelled as an immutable record CounterChangedEvent. It carries four pieces of information: the previous value, the new value, the action that triggered the change, and a timestamp set when the event is created. The choice of a Java record here is not an end in itself but a semantic statement. An event is, by its nature, immutable. Anything that could be retrospectively altered would amount to rewriting history, which in an audit context would be all but absurd.

Above this state class sits a thin service CounterService, which serves outwardly as the sole point of contact for operations on the counter. It delegates the operations to CounterState, takes the resulting event, and distributes it to all registered listeners. This separation between state and service may seem over-engineered for the trivial domain considered here, but it becomes important later in the article. The service is the point at which the article’s extension mechanism takes hold. Any number of listeners can be attached to it, without the state class itself needing to know of their existence.

With that, the domain foundation for everything that follows is in place. The community edition will make the counter visible via Vaadin and offer the three operations as buttons. The enterprise extension will not interfere with the domain itself; it will merely contribute listeners that intercept each event and process it for their own purposes, be it a history view, an audit log, or an export view. Nothing more than this brief sketch is needed at this point.


4. The extension API

The extension API is the contract surface where core and extension meet. It defines the form in which a module may bring itself into a running application, and it equally defines what it must not do. Both aspects are equally important. An overly powerful extension API may seem flexible in the short term, but it undermines the very properties for which the open-core model was chosen in the first place, namely, the predictability of the core in the face of arbitrary extensions.

At the centre stands the interface FeatureContribution. An implementation of this interface represents exactly one functional unit that joins the application. It carries a meaningful identifier indicating its origin, along with three contributions that may be incorporated into the overall system: a list of routes, a list of menu entries, and a list of navigation bar additions. There is also an optional sort order, against which contributions are arranged during assembly. The interface contains nothing further. It is deliberately restricted to the descriptive and contains no method that could actively change the system. The methods for navbar additions and sort order come with default implementations, so an extension only needs to override the methods whose contributions it is interested in.

public interface FeatureContribution {
  String id();
  List<RouteContribution> routes();
  List<MenuContribution> menuItems();
  default List<NavbarContribution> navbarItems() {
    return List.of();
  }
  default int order() {
    return 1000;
  }
}

The two contribution types, RouteContribution and MenuContribution, are designed as immutable data records. A RouteContribution carries the relative path and the associated Vaadin component class; a MenuContribution additionally carries a display label, its own sort order, and an icon identifier. Both are modelled as records, since they are pure descriptive objects without behaviour. In both cases, a compact constructor is provided that guards the mandatory fields against null and, for the route, additionally prevents the path from beginning with a leading slash.

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");
  }
}

The third contribution type, NavbarContribution, deliberately departs from this pattern. It is modelled not as a record but as an interface. The reason lies in a peculiarity of Vaadin. A Vaadin component can be attached to only one parent in the component tree at any given time. If the extension were to supply a ready-made component instance as the contribution, it could not reliably be attached to multiple UI instances. For this reason, a NavbarContribution does not deliver a finished component but rather a factory function, a Supplier<Component>, that produces a new instance each time. In addition to this factory, it carries an identifier for diagnostic purposes and a sort order whose lower values place an entry further to the left in the navigation bar. The distinction between record and interface is not arbitrary; it follows directly from the properties of the underlying UI framework. Where descriptive objects suffice, records are used; where instantiation per use case is required, an interface takes their place.

public interface NavbarContribution {
  String id();
  Supplier<Component> componentFactory();
  default int order() {
    return 1000;
  }
}

For observing the counter, a fourth, complementary contract is provided. The interface CounterEventListener describes a single listener that is notified of each value change. Since not every extension is necessarily interested in these events, the option of contributing listeners was not added to FeatureContribution itself but moved into a specialised sub-variant CounterEventFeature, which extends the base interface with the method counterEventListeners. An extension that wishes only to contribute new views still implements only FeatureContribution. An extension that also wishes to react to counter events implements CounterEventFeature and supplies a list of its listeners there. This split follows the principle that an interface should require only the methods indispensable to its purpose, without dragging along unused appendages.

public interface CounterEventListener {
  void onCounterChanged(CounterChangedEvent event);
}
public interface CounterEventFeature extends FeatureContribution {
  List<CounterEventListener> counterEventListeners();
}

What the API does not permit is just as instructive as what it does. An extension cannot replace an existing route, cannot exchange the implementation of CounterService, cannot hook into the main layout, and cannot rebuild the application context. It can only register new views, contribute new menu entries, place additional elements in the navigation bar, and attach new listeners to the counter event. The extension acts additively, never invasively. This restriction may seem strict, but it is a deliberate architectural trade-off. What the API loses in power, it gains in stability. The core can evolve independently without an extension with a life of its own, breaking, as long as the few structures agreed upon here remain unchanged.

Diagram 2: Class diagram of the extension API
Diagram 2: Class diagram of the extension API


5. Discovery via the ServiceLoader and the FeatureRegistry

With the API described in the previous chapter, only the form in which extensions may be introduced has been fixed. What remains open is how the core learns of the existence of these extensions at runtime. An extension cannot actively push itself into the core; it must rather be passively discoverable. This calls for a discovery mechanism that combines two seemingly contradictory properties. It must automatically discover new contributions and, at the same time, provide a fully transparent account of their origins. The ServiceLoader from the Java standard library satisfies both requirements and is used in this project as the sole discovery mechanism.

The ServiceLoader has been part of the JDK since Java 6 and involves no magic. Every module that wishes to provide a contribution places a text file on its classpath whose name corresponds to the fully qualified name of the service interface, and whose contents list the fully qualified names of the implementation classes line by line. At runtime, the ServiceLoader classloader queries the classpath for all occurrences of this file, loads the named classes, calls their no-argument default constructor, and returns the resulting objects. Nothing more happens. There is no reflection-based classpath scan, no annotation processing, and no hidden application context is built.

In the present article, the relevant file in both modules is named META-INF/services/com.svenruppert.opencore.counter.extension.FeatureContribution. Its content differs per module. In the community project, it points to com.svenruppert.opencore.counter.ui.core.CoreFeatureContribution; in the enterprise project, to com.svenruppert.opencore.counter.enterprise.EnterpriseFeatureContribution. The mere presence of the respective JAR file on the classpath is enough to activate the contribution it contains. If the enterprise JAR is removed, the application runs as a pure community edition; if it is added, its additional views, menu entries, navbar additions, and event listeners appear. The process is declarative and fully documented within an application’s classpath.

The task of bringing the result of the ServiceLoader into a form usable by the core is taken on by the class FeatureRegistry. It is designed as an immutable component object that performs all its work in its constructor. The invocation of the ServiceLoader itself is moved into a private static helper method whose sole purpose is to translate the Iterable returned by the ServiceLoader into a conventional list.

private static List<FeatureContribution> loadViaServiceLoader() {
  List<FeatureContribution> result = new ArrayList<>();
  for (FeatureContribution contribution :
       ServiceLoader.load(FeatureContribution.class)) {
    result.add(contribution);
  }
  return result;
}


Diagram 3: Sequence of contribution discovery at startup

The loaded contributions are then ordered by their sort order. Routes, menu entries, navbar additions, and event listeners are then collected into their own, likewise immutable, lists. All checks whose violations must prevent startup also occur here. The following therefore, holds: if a FeatureRegistry can be created successfully, the assembled configuration is internally consistent, and the downstream system can dispense with any further validation.

Conflict handling follows a clear rule. Duplicate route paths cause an IllegalStateException, as do duplicate menu entries, in which the combination of label and path serves as the key. This asymmetry is intentional, since two different menu entries may well point to the same path as long as they differ in the displayed label. Each exception carries an expressive message that names both the offending contribution and the feature that caused the conflict. The fact that the application aborts at startup with a meaningful error message rather than continuing with half-registered routes is the variant of the “fail fast” principle chosen here.

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);
  }
}

A second observation is worth making when collecting the event listeners. Since only those contributions can deliver an entry here that implement the sub-interface CounterEventFeature, the registry checks per contribution whether it is a CounterEventFeature and only then calls the relevant method. Pattern matching instanceof reduces this check to a single line.

List<CounterEventListener> collectedListeners = new ArrayList<>();
for (FeatureContribution feature : this.features) {
  if (feature instanceof CounterEventFeature eventFeature) {
    collectedListeners.addAll(eventFeature.counterEventListeners());
  }
}

In addition to the no-argument constructor used by the application, the registry offers a second, parameterised constructor that accepts a predefined list of contributions. This is used solely for testability. It allows the sort and conflict logic to be tested against hand-crafted constellations without having to deceive the ServiceLoader itself through classpath manipulation.

From the variety of possible discovery mechanisms, the ServiceLoader was preferred for several reasons. A reflection-based classpath scan, as offered by libraries such as Reflections or ClassGraph, can deliver very similar results but adds an extra dependency and leaves it to the library to decide what counts as a contribution. Annotation-based mechanisms, such as those used by Spring or CDI, shift registration from an explicit text file to an annotation in the source code. What appears shorter, however, is tied to the platform and makes it difficult to determine which contributions are included in a given classpath without dedicated tooling. The ServiceLoader, by contrast, requires no additional dependencies, is included in every JDK, and brings all registrations together in a single, visible file. This visibility is especially valuable in the open-core context. Anyone wishing to know which modules participate in a running system can find the answer by glancing at the unpacked JAR files, without consulting the core’s source code.


6. Vaadin integration: AppLayout and dynamic routes

With the mechanism described in the previous chapter, all contributions are in ordered and validated form by the time it FeatureRegistry exits its constructor. What remains is to translate these contributions into structures that Vaadin can actually display and evaluate. This task is divided between two components. The first is the main layout MainLayout, which brings menu entries into the navigation area and navbar additions into the upper area. The second is the OpenCoreRouteInitializer, one that deposits the gathered routes into the Vaadin route system at the right moment. Together, these two components form the only Vaadin-specific layer of the open-core build.

MainLayout extends AppLayout and is a perfectly ordinary Vaadin component, with one peculiarity. It carries the annotation @Layout("/"). This annotation is necessary because Vaadin relies on static analysis of the source code to produce the production frontend bundle. The bundle scanner searches the bytecode for @Route and @Layout annotations, follows their references, and determines from this the set of Vaadin components that must be included in the bundle. Since the current design has no view carrying an @Route annotation, MainLayout without @Layout would be invisible to the bundle scanner, resulting in the components and themes used in the layout not being included in the production bundle. The annotation thus has a purely technical, build-related function and does not affect the extension mechanism.

In the layout’s constructor, the navigation bar and drawer are set up. The drawer contains a SideNav component that is populated with the menu entries from the registry. Since these were already ordered by sort order when the registry was built, the layout can simply iterate over the list and is freed of any concern with sorting. Each entry is turned into a SideNavItem path that is prefixed with a leading slash, as the Vaadin router expects.

The navigation bar is likewise fed from the registry. If navbar additions are present, they are grouped into their own HorizontalLayout and placed to the right of the title. It is here that the decision described in chapter four to model NavbarContribution as an interface with a Supplier<Component> factory proves its worth. The layout calls the factory, receives a fresh component instance, and can attach it without further precaution.

@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;
  }
}

What is remarkable is what the layout omits. It carries no import statement on any class from the enterprise module, knows neither HistoryView nor AuditLogView, and performs no case distinction based on the presence or absence of extensions. The only source from which the layout draws is the FeatureRegistry. What it delivers appears in the drawer and in the navigation bar; what it does not deliver simply is not there. It is precisely this uncompromising binding to the registry that allows the layout to function identically for both editions.

The second Vaadin integration point is the OpenCoreRouteInitializer. It implements Vaadin’s own interface VaadinServiceInitListener, whose serviceInit method is invoked exactly once when the Vaadin service starts up. Precisely this moment is suitable for transferring the routes collected FeatureRegistry into the running Vaadin configuration. The initialiser obtains the RouteConfiguration responsible for the application scope, iterates over the list of route contributions, and registers each path together with the corresponding view class in the Vaadin route registry. MainLayout is set as the layout for each of these routes, so that all dynamically registered views appear within the same frame.

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);
        }
      }
    });
  }
}

Diagram 4: Vaadin integration across two lifecycle phases
Diagram 4: Vaadin integration across two lifecycle phases

Announcing the initialiser to Vaadin once again follows the ServiceLoader pattern, this time along a service interface defined by Vaadin itself. The file META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener contains a single line with the fully qualified name of the initialiser. With that, the mechanism is closed. The application’s own contributions are discovered via the self-defined FeatureContribution service interface, while Vaadin’s processing of these contributions is hooked in via Vaadin’s own VaadinServiceInitListener service interface. Both interfaces are made discoverable via the same mechanism included in the JDK.

The deliberate omission of @Route annotations is the immediate consequence of this architecture. An @Route annotation would tie a view to a path at compile time and thereby remove it from dynamic assignment. The extension mechanism would be restricted to views that had agreed on a fixed path, thereby undermining the separation between core and extension. In the present design, by contrast, only the RouteContribution determines which path a view is reachable via. A contributor can change a path without affecting the view itself, and the core remains unaware of the paths of individual contributions. The @Layout annotation, which MainLayout carries as the sole exception, is not affected by this, since it does not define a path but merely announces its existence to the bundle scanner.


Transition to part two

With this, the first part of this article comes to an end. With the extension API, the FeatureRegistry as the central point of contact for the contributions discovered at runtime, and the Vaadin-side translation into routes, menu entries, and navbar additions, the mechanism of the open-core application is fully described.

What still remains is its concrete application in the example system, that is, connecting the community edition and the enterprise extension to this mechanism, along with the question of how the whole thing can be operated via an embedded servlet container and continuously protected against boundary violations through tests. These practical aspects are the subject of the second part.

Read part II here: https://3g3.eu/ihlakl

Total
0
Shares
Previous Post

OpenJ9 GC Policies: How to Choose the Right Collector

Next Post

Open-Core with Core Java and Vaadin — Part 2

Related Posts