Skip to main content

Decoupling expanders

· 6 min read
Frédéric Hannes
Frédéric Hannes
R&D Engineer

As described in the post for the release of Expanders 5.33.0, we've recently introduced a new system to handle imports in expanders. We've since been slowly migrating more and more expanders to use this new system, which does bring frequent breaking changes with it in the form of missing imports. We don't specifically document these changes, since adding an import in custom code is merely a formality. But this change has exposed unwanted coupling in some expanders that could be avoided. In this post I'd like to go over some of these issues and how they can be avoided.

caution

It is important to keep in mind that expansion resources are independent projects, but may extend each other. They should be designed with care to make sure that if there are dependencies, you build that dependency in such a way that there's sufficient flexibility for them to evolve independently. Every resource will depend on a specific version of the other, so updating this minimum dependency is what should guarantee that they are still compatible. Stack resources can also be created to group multiple expansion resources that are compatible, to make it more convenient to use them together.

Over-specifying expander feature conditions

An expander feature is a way of extending generated code with new functionality that often relates to a crosscutting concern. The way this works is similar to adding custom code into an expanded artifact. There are special anchors in the code, that starts with @anchor, which are places where expander features can be added. A common pitfall in designing features is over-specifying the enable condition for the feature.

Example

Let's say that you have a feature that adds some code in class A, this class is generated by an expander that checks if option enableA is present on an element. If you want to inject code into that class for a feature, you will typically enable that feature with a specific option, such as enableMyFeature. If this feature extends some functionality that is more constrained to that class A, often the condition for that feature will be enableA && enableMyFeature. What is important to realize here is that your feature should only check the conditions for enabling the feature itself. If enableA is not present, then the artifact A will not be generated and as a result, the feature will not insert any code in that artifact anyway.

Now on the other end, the expander represents a specific artifact, which is why you want to add a feature to it. If the condition to enable that expander would be changed, you typically will still want to add that feature, since the meaning of the artifact should not be different. But if you were checking the original condition of that expander along with that of the feature, this may no longer be compatible and the feature might not be added.

Example

As in the previous example, the expander for A could now be enabled with enableMyFunctionality that also enables an expander for B. If you had the condition enableA && enableMyFeature for your feature, it will no longer be injected, even though A is still generated. If you just checked enableMyFeature, it would still work, since your feature still injects into that same expander for artifact A.

Classpath dependencies

If you depend on another expansion resource, that resource's contents will be available to you on the classpath, as this is how Java libraries work. This is required for you to have access to the expander and modelling frameworks. But this also comes with a risk. It is tempting to use helper classes or shared mapping files from an expansion resource dependency directly in your own helpers or mapping files, but this should be avoided at all cost.

These kinds of files have no guarantee of stability and are intended to be used for functionality of a specific expansion resource. By depending on them directly, you either constrain the flexibility of that parent resource, or you risk your resource breaking at random when these files change.

Anti-pattern

Including other mapping files using <include/> statements or delegating a mapping to Java code is an anti-pattern and should be avoided unless there's no good way of representing the mapping using OGNL statements in the mapping file for an expander. There are exceptions, but these are rare and the need to use these constructs is often a sign that there is something missing in the model, since mapping files should usually not be very complicated.

Feature mappings

When creating a feature expander, you must specify a mapping for this feature. The feature does however have access to the mapping of the expander it injects into. While by itself this is not such an issue, as some implicit mappings are always present, a feature expander should not use the mappings of the expander it injects code into. All mappings for a feature should be provided by that feature itself in its mapping file, so it does not break when the mapping of the parent expander changes. This means that the mapping for the feature will only depend on the model itself, which is overall very stable.

Imports in features

Originally we just expanded imports for Java code in the files directly and anchors are typically present to add more for custom code and with a feature expander. We've recently added support to define imports in the mapping file to have a more clean and consistent way of adding these imports and also allowing us to remap imports in cases where it is needed, such as for the transition from javax to jakarta.

With the new import system we've also switched to a new approach of aiming to only add imports in expanded code that are used by that code, so this is self-contained. This has however also shown that a lot of feature expanders do not do this and rely on the imports of the expander they inject code into.

With the new system, the aim should be for a feature expander to define EVERY import it needs for the code to compile in its mapping file with <uses/> statements. All imports will be merged into one pool and only unique imports will be added to the expanded file in the end, so this does not risk adding a bunch of duplicate imports. With this approach, any changes to imports in the parent expander will not affect the expanded feature and will allow them to evolve independently more freely.

tip

The FeatureExpanderTester has a new method testImports(CompositeProjection), that can be used to create a test for the imports generated by a feature. This works the same way as testBase(CompositeProjection), in that it will test against a string template in the test template file.