placeholder

Combating modularization anti-patterns with Contribution Locator

author

Thomas Klambauer

October 3, 2022

We previously discussed modularizing an application to reduce build and development time. A common roadblock in this process is code that centralizes a specific detail of all modules in a single place.

Example

Let us consider an example application where one module implements a feature flag system:

Other modules can define their feature flags statically and then, at runtime, query their current state, thus behaving differently depending on whether the flag is on or off:

package featureFlagsAPI;
interface FeatureFlagService {
boolean isEnabled(String id);
}

This could be, for instance, a flag to completely disable the monitoring module functions. Let us assume several such flags exist, including in other modules, and suppose the ID constants for each flag are declared in the domain-specific module where they’re used. This would mean that the disable-monitoring flag would be part of the monitoring implementation module:

package monitoringImpl;
class MonitoringImplService {
public static String FLAG = “Monitoring”;
// …
if (featureFlagService.isEnabled(FLAG))
//…
}

Use case: List all contributors

A useful functionality of such feature flag service could be to provide a user interface listing all feature flags across the whole application, displaying their current state and offering buttons to switch them on and off.

This functionality would naturally be part of the feature flag implementation module. But how would the feature flag service retrieve a complete list of all feature flag IDs?

Anti-pattern solution 1: Reference all implementations

The feature flag service and its implementation module could reference all other implementation modules with a feature flag.

However:

  1. Implementations should not depend on other implementations, and
  2. This won’t scale because if every service applied this pattern, it would result in circular dependencies between the implementation modules

Anti-pattern solution 2: Centralization in API

Another option would be to not keep the feature flag ID string constants together with the code that uses it, but centrally in the feature flags API module:

package featureFlagsAPI;
class FeatureFlags {
public static String MONITORING_FLAG = “Monitoring”;
public static String PERSISTENCE_FLAG = “Persistence”;
// … many more
}

However:

  1. Every change to this list, like the addition of a new feature flag, would cause all implementation modules that use feature flags to re-compile, although that would only be necessary for one
  2. For projects with high-frequency contributions, this file will regularly cause merge conflicts
  3. The feature flag constant is domain-specific code that should be in the domain-specific module — here, the monitoring module. Otherwise, when deleting the whole monitoring module, this is leftover code that will be forgotten and remain as technical debt. This change amplification causes unneeded complexity.¹

Anti-pattern solution 3: Centralization in the Root module

A similar approach centralizes all feature flags in the root module:

package root;
class FeatureFlags {
public static String MONITORING_FLAG = “Monitoring”;
public static String PERSISTENCE_FLAG = “Persistence”;
// … many more
static List<String> getAll() {
return List.of(MONITORING, FLAG, … );
}
}

class Application {
void main() {
ff = new FeatureFlagServiceImpl(FeatureFlags.getAll());
}
}

However:

  1. This gives up on modularization, with the same drawbacks #2 and #3 mentioned in the previous section
  2. As the root module re-compiles with nearly every code change — for instance, to any API, now the class FeatureFlags will similarly re-compile unnecessarily

Solutions

Keeping the domain-specific contributions (feature flags IDs) in the domain-specific modules avoids all the mentioned problems. Based on this, let us compare the different solutions.

Solution “Contribution Locator” using Java ServiceLoader

We can use a variation of the service locator pattern to load all implementations of a “service” interface. Each contribution is another implementation of that interface. Using the Java ServiceLoader mechanism, we can compile a complete list of all such contributions. Let us call this pattern Contribution Locator:

package featureFlagsAPI;
interface FeatureFlagContributor {
void contribute(FeatureFlagService ff)
}
interface FeatureFlagService {
void register(String id);
}

package featureFlagsImpl;
class FeatureFlagServiceImpl implements FeatureFlagService {
Set<String> ids;
void register(String id) {
ids.add(id);
}
void init() {
for (FeatureFlagContributor contrib : ServiceLoader.load(FeatureFlagContributor.class)) {
contrib.contribute(this);
}
}
}

package monitoringImpl;
class MonitoringFlagsContributor implements FeatureFlagContributor {
static String FLAG = “Monitoring”;
// …
void contribute(FeatureFlagService ff) {
ff.register(FLAG);
}
}
class MonitoringImpl {
MonitoringImpl(FeatureFlagService ff) {
this.ff = ff;
}
void monitor() {
if (ff.isEnabled(MonitoringFlagsContributor.FLAG))
// do something
}
}

See this example app implementing this approach.

Solution “Static wiring”

In the previous example, the modules aren’t only contributing feature flag IDs but are also using the feature flag service by calling isEnabled. So, we could instead register the flags when receiving the FeatureFlagService reference via the constructor or a setter method:

class MonitoringImpl {
MonitoringImpl(FeatureFlagService ff) {
this.ff = ff;
this.ff.register(FLAG);
}
void monitor() {
if (ff.isEnabled(MonitoringFlagsContributor.FLAG))
// do something
}
}

This solution does not exhibit the problems of the anti-patterns; it is less complicated than the ServiceLoader approach and may be sufficient in simple situations.

However, when things get more complex, static wiring exhibits some downsides in comparison with the ServiceLoader solution:

  1. Every contributor must be instantiated before the FeatureFlagService.getAll() method is called; otherwise the service and its users might operate with an incomplete list
  2. The FeatureFlagService does not know when/if the list is complete, whereas when loading all instances via ServiceLoader, this is guaranteed
  3. In situations where only registration is done, but no other usage of the service is required:
  • In nested modules or deeply layered code, static wiring will require passing the registration interface down the call chain
  • If a circular dependency exists between the feature flag service and another service, the static wiring may require moving registration code from constructors to setters, but no changes are needed for the ServiceLoader

Remaining problem: ID clashes

All discussed options — solutions and anti-patterns — could experience an ID clash if the same feature flag ID is used across multiple contributors. This can be addressed by:

  • Detecting clashes during registration and aborting
  • Adding an integration test that detects when clashes happen and fail
  • For each contribution, manually generate and then hard-code UUIDs for all IDs
  • If names are not changed, then Java-package and class-names could be used as prefixes

Generalization

We took a simple feature flag service and string IDs as the basis for discussion. In practice, the contributions will often be more complex. For instance, the service might require additional features like flag text descriptions, default values, etc. The usage of complex objects is indicated as follows:

class FeatureFlagInfo {
String id;
Boolean defaultValue;
String description;
}
class MonitoringFlagsContributor implements FeatureFlagContributor {
static FeatureFlagInfo FLAG = new FeatureFlagInfo(“Monitoring”, true, “…”);
void contribute(FeatureFlagService ff) {
ff.register(FLAG);
}
}

We saw similar “contribution patterns” also emerge in other domains like:

  • Render-code to visualize a specific payload type read from a generic persistence service
  • Contributions to a generic settings-framework, defining config option names and values
  • Servlets and paths under which they’re registered

The more complex such contributions become, the more dependencies they require. So, when implementing rendering code for objects of a specific Java class, this will require module dependencies on (1) the payload class and (2) the UI framework used for rendering.

These dependencies are fine when added to a specific implementation module. Still, they will lead to circular dependencies and increased build times in complex apps, for instance, if the anti-patterns “Reference all implementations” or “Centralization in the API” are used.

The presented solutions avoid such dependency issues as apps get increasingly complex.

Summary

Building upon the proposal in the previous blog post to modularize applications, we illustrated the typical use case of having distributed contributions across a modularized code base needed in a central location, as shown with an example feature flag service.

We identified several approaches that solve this but argued that these should be seen as anti-patterns due to various drawbacks. We instead proposed the Contribution Locator pattern based on the Java ServiceLoader. This pattern can be seen as an inversion of control and application of the dependency inversion principle.

Applying this pattern in the mentioned situation achieves better modularization by keeping module-specific code in the pertaining module, yielding the following advantages:

  • Fewer merge conflicts
  • Increased maintainability
  • Faster builds (due to reduction of dependencies and keeping contributions an implementation detail and not part of the API layer).

Footnotes

[1] John Ousterhout in A philosophy of software design


Combating modularization anti-patterns with Contribution Locator was originally published in Dynatrace Engineering on Medium, where people are continuing the conversation by highlighting and responding to this story.

Written by

author

Thomas Klambauer