You already know Build Compatible Extensions

03 Dec 2021 - Ladislav Thon

As you have learned from a previous post, CDI Lite comes with a new extension API called Build Compatible Extensions. In this post, you will learn more about this API by comparison with Portable Extensions and my hope is that by the end, you will see that you already know Build Compatible Extensions.

If you don’t know Portable Extensions, don’t worry. The Build Compatible Extensions API has detailed documentation and all examples in this text should be easy to follow for anyone.

Extension API

Build Compatible Extensions, similarly to Portable Extensions, are service providers for the BuildCompatibleExtension interface. Contrary to Portable Extensions, Build Compatible Extensions do not declare observer methods for CDI events. This is a major design goal of the entire CDI Lite effort: make it possible to perform bean discovery before the application is started, such as during compilation. At that point in time, the CDI container obviously doesn’t exist, so it can’t deliver events.

That said, Build Compatible Extensions are still pretty close to what you’re used to. Instead of observer methods, you declare methods annotated with one of the execution phase annotations. On these methods, you then declare parameters that let you affect the bean discovery process. Each execution phase allows a certain set of parameter types to be declared.

The Build Compatible Extensions API has 5 execution phases. These phases roughly correspond to Portable Extensions lifecycle events:

Build Compatible Extensions Portable Extensions
@Discovery BeforeBeanDiscovery
@Enhancement ProcessAnnotatedType
@Registration ProcessBean, ProcessObserverMethod (read-only)
@Synthesis AfterBeanDiscovery
@Validation AfterDeploymentValidation

This covers the most important events in Portable Extensions. One significant part is intentionally missing, and that’s an equivalent of the ProcessBeanAttributes event, plus the modification part of ProcessObserverMethod. If deemed necessary, these could be added to Build Compatible Extensions in the future.

A simple Hello, world! extension would look like this:

// the corresponding META-INF/services/jakarta.enterprise.inject.build.compatible.spi.BuildCompatibleExtension file must also exist
public class MyExtension implements BuildCompatibleExtension {
    @Discovery
    public void hello(Messages msg) {
        msg.info("Hello, world!");
    }
}

Messages is one of the types that extension methods can declare as a parameter. Let’s take a look at all the phases and compare them to Portable Extensions in more detail.

@Discovery

In @Discovery, you can add types to be scanned during bean discovery and register custom meta-annotations. Extension methods annotated @Discovery can declare parameters of these types:

This is very similar to the BeforeBeanDiscovery event:

Build Compatible Extensions Portable Extensions
ScannedClasses.add BeforeBeanDiscovery.addAnnotatedType
MetaAnnotations.addQualifier BeforeBeanDiscovery.configureQualifier
MetaAnnotations.addInterceptorBinding BeforeBeanDiscovery.configureInterceptorBinding
MetaAnnotations.addStereotype BeforeBeanDiscovery.addStereotype
MetaAnnotations.addContext BeforeBeanDiscovery.addScope + AfterBeanDiscovery.addContext

One noticeable difference is that when adding a custom scope, you don’t provide a context object. Instead, you provide a class of the context object, which is then instantiated at runtime (and so must have a public zero-parameter constructor).

@Enhancement

In @Enhancement, you can look at discovered types and modify annotations on them. Extension methods annotated @Enhancement can declare parameters of these types:

Each @Enhancement method must declare exactly 1 parameter of type ClassInfo, MethodInfo, FieldInfo, ClassConfig, MethodConfig, or FieldConfig.

When the method declares a parameter of type ClassInfo or ClassConfig, it is called once for each matching type. When it declares a parameter of type MethodInfo or MethodConfig, it is called once for each method and constructor of each matching type. Finally, when it declares a parameter of type FieldInfo or FieldConfig, it is called once for each field of each matching type.

How are these “matching types” determined? The @Enhancement annotation itself has members that, collectively, express this set:

The types and withSubtypes members corresponds to a type argument that you would apply to the ProcessAnnotatedType generic event type. The withAnnotations member corresponds to the @WithAnnotations annotation.

As an example, let’s move a qualifier from one class to another:

// 1. a qualifier annotation
@Qualifier
@Retention(RUNTIME)
public @interface MyQualifier {
}

// 2. a service interface
public interface MyService {
    String hello();
}

// 3. two implementations, one with qualifier and the other unqualified
@Singleton
@MyQualifier
public class MyFooService implements MyService {
    @Override
    public String hello() {
        return "foo";
    }
}

@Singleton
public class MyBarService implements MyService {
    @Override
    public String hello() {
        return "bar";
    }
}

// 4. a class that uses the service
@Singleton
public class MyServiceUser {
    @Inject
    @MyQualifier
    MyService myService;
}

// 5. the extension
public class MyExtension implements BuildCompatibleExtension {
    @Enhancement(types = MyFooService.class)
    public void foo(ClassConfig clazz) {
        clazz.removeAnnotation(it -> it.name().equals(MyQualifier.class.getName()));
    }

    @Enhancement(types = MyBarService.class)
    public void bar(ClassConfig clazz) {
        clazz.addAnnotation(MyQualifier.class);
    }
}

The API is used somewhat differently than ProcessAnnotatedType, because in Portable Extensions, you first have to get an AnnotatedTypeConfigurator. From there, however, the correspondence is pretty clear:

Build Compatible Extensions Portable Extensions
ClassConfig.info AnnotatedTypeConfigurator.getAnnotated
ClassConfig.addAnnotation AnnotatedTypeConfigurator.add
ClassConfig.removeAnnotation AnnotatedTypeConfigurator.remove
ClassConfig.removeAllAnnotations AnnotatedTypeConfigurator.removeAll
ClassConfig.constructors AnnotatedTypeConfigurator.constructors
ClassConfig.methods AnnotatedTypeConfigurator.methods
ClassConfig.fields AnnotatedTypeConfigurator.fields
MethodConfig.info AnnotatedMethodConfigurator.getAnnotated, AnnotatedConstructorConfigurator.getAnnotated
MethodConfig.addAnnotation AnnotatedMethodConfigurator.add, AnnotatedConstructorConfigurator.add
MethodConfig.removeAnnotation AnnotatedMethodConfigurator.remove, AnnotatedConstructorConfigurator.remove
MethodConfig.removeAllAnnotations AnnotatedMethodConfigurator.removeAll, AnnotatedConstructorConfigurator.removeAll
MethodConfig.params AnnotatedMethodConfigurator.params, AnnotatedConstructorConfigurator.params
ParameterConfig.info AnnotatedParameterConfigurator.getAnnotated
ParameterConfig.addAnnotation AnnotatedParameterConfigurator.add
ParameterConfig.removeAnnotation AnnotatedParameterConfigurator.remove
ParameterConfig.removeAllAnnotations AnnotatedParameterConfigurator.removeAll
FieldConfig.info AnnotatedFieldConfigurator.getAnnotated
FieldConfig.addAnnotation AnnotatedFieldConfigurator.add
FieldConfig.removeAnnotation AnnotatedFieldConfigurator.remove
FieldConfig.removeAllAnnotations AnnotatedFieldConfigurator.removeAll

ProcessAnnotatedType.getAnnotatedType corresponds to declaring a parameter of type ClassInfo. That type is described below, in the Langugae Model section.

Careful reader will note that ProcessAnnotatedType allows creating a whole new AnnotatedType manually and supplying it to the container. Build Compatible Extensions do not allow this style of usage, only the simplified and more straightforward *Configurator style.

@Registration

In @Registration, you can look at registered beans, interceptors, and observers. Extension methods annotated @Registration can declare parameters of these types:

Each @Registration method must declare exactly 1 parameter of type BeanInfo, InterceptorInfo, or ObserverInfo.

When the method declares a parameter of type BeanInfo, it is called once for each bean whose set of bean types contains at least one given type. When it declares a parameter of type InterceptorInfo, it is called once for each interceptor whose set of bean types contains at least one given type. Finally, when it declares a parameter of type ObserverInfo, it is called once for each observer whose observed event type is assignable to at least one given type.

Similarly to @Enhancement, these “given types” come from the @Registration annotation itself. The annotation has a single mandatory member called types whose value is the set of “given types”. The types member corresponds to a type argument that you would apply to the ProcessBean or ProcessObserverMethod generic event types.

Again, Portable Extensions are somewhat different in usage, but there’s a direct correspondence:

Build Compatible Extensions Portable Extensions
BeanInfo Bean obtained from ProcessBean
InterceptorInfo Interceptor obtained from ProcessBean
ObserverInfo ObserverMethod obtained from ProcessObserverMethod

@Synthesis

In @Synthesis, you can register synthetic beans and observers. Extension methods annotated @Synthesis can declare parameters of these types:

As an example, let’s create a synthetic bean. Synthetic observers are very similar.

// 1. a bean class
public class MyPojo {
    public final String data;

    public MyPojo(String data) {
        this.data = data;
    }
}

// 2. bean creation function
public class MyPojoCreator implements SyntheticBeanCreator<MyPojo> {
    @Override
    public MyPojo create(Instance<Object> lookup, Parameters params) {
        String name = params.get("name", String.class);
        return new MyPojo("Hello " + name);
    }
}

// 3. bean disposal function
public static class MyPojoDisposer implements SyntheticBeanDisposer<MyPojo> {
    @Override
    public void dispose(MyPojo instance, Instance<Object> lookup, Parameters params) {
        System.out.println("disposing " + instance.data);
    }
}

// 4. the extension
public static class MyExtension implements BuildCompatibleExtension {
    @Synthesis
    public void synthesise(SyntheticComponents syn) {
        syn.addBean(MyPojo.class)
                .type(MyPojo.class)
                .withParam("name", "World")
                .createWith(MyPojoCreator.class)
                .disposeWith(MyPojoDisposer.class);
    }
}

An important observation is that the MyExtension code runs during bean discovery, which may be during application compilation, while the MyPojoCreator and MyPojoDisposer functions run at runtime. The Parameters map is transferred from the extension code to the runtime code. What kind of parameter types are possible? Essentially anything that you can put into an annotation, including annotations themselves.

Comparison with Portable Extensions is straightforward:

Build Compatible Extensions Portable Extensions
SyntheticComponents.addBean AfterBeanDiscovery.addBean
SyntheticComponents.addObserver AfterBeanDiscovery.addObserverMethod

Similarly to @Enhancement, Build Compatible Extensions do not have an equivalent of creating a whole new Bean or ObserverMethod object from scratch. Only the simplified and more straightforward *Configurator style is present.

@Validation

In @Validation, you can perform custom validation. Extension methods annotated @Validation can declare parameters of these types:

Comparison with Portable Extensions is obvious:

Build Compatible Extensions Portable Extensions
Messages.error AfterDeploymentValidation.addDeploymentProblem

At this point, you may be asking, what’s the real difference? Isn’t this just a different set of names for existing concepts? Is a new API truly necessary?

The answer, again, comes from the architectural constraint that all this must be implementable during application compilation. This means the CDI container can’t be running, but it also means that using reflection is costly or even forbidden. Portable Extensions, unfortunately, are very reflection-heavy. That’s why, in addition to a new extension API, we also had to create a new language model API. All those weird types called ClassInfo etc. are part of that. In the following section, we’ll describe the API in more detail and compare with existing similar APIs.

Language Model

The jakarta.enterprise.lang.model API models the Java language from a high-level perspective. It is completely reflection-free, though it may easily be implemented on top of reflection. The core value is that it can also be implemented on top of other representations, such as javax.lang.model or Jandex.

The model is structured as a hierarchy of types. The root in this hierarchy is AnnotationTarget. That is anything that can be annotated:

Annotations are represented by AnnotationInfo. An AnnotationInfo has a String-keyed map of annotation members, represented by AnnotationMember.

Build Compatible Extensions Portable Extensions Reflection Annotation Processing Jandex
AnnotationTarget jakarta.enterprise.inject.spi.Annotated java.lang.reflect.AnnotatedElement javax.lang.model.AnnotatedConstruct org.jboss.jandex.AnnotationTarget
AnnotationInfo none, uses java.lang.Annotation none javax.lang.model.AnnotationMirror org.jboss.jandex.AnnotationInstance
AnnotationMember none1 none javax.lang.model.element.AnnotationValue org.jboss.jandex.AnnotationValue
  1. Reflective access is possible, but usually not necessary.

As mentioned above, there are two kinds of AnnotationTargets: declarations and types. Therefore, we have DeclarationInfo as the top-level type for representing Java declarations, and Type as the top-level type for representing Java types.

Declarations are:

Build Compatible Extensions Portable Extensions Reflection Annotation Processing Jandex
DeclarationInfo none, just jakarta.enterprise.inject.spi.Annotated none4 javax.lang.model.element.Element none, just org.jboss.jandex.AnnotationTarget
PackageInfo none1 java.lang.Package javax.lang.model.element.PackageElement none5
ClassInfo jakarta.enterprise.inject.spi.AnnotatedType2 java.lang.Class javax.lang.model.element.TypeElement org.jboss.jandex.ClassInfo
FieldInfo jakarta.enterprise.inject.spi.AnnotatedField2 java.lang.reflect.Field javax.lang.model.element.VariableElement org.jboss.jandex.FieldInfo
MethodInfo jakarta.enterprise.inject.spi.AnnotatedMethod, jakarta.enterprise.inject.spi.AnnotatedConstructor2 java.lang.reflect.Method, java.lang.reflect.Constructor javax.lang.model.element.ExecutableElement org.jboss.jandex.MethodInfo
ParameterInfo jakarta.enterprise.inject.spi.AnnotatedParameter2 java.lang.reflect.Parameter javax.lang.model.element.VariableElement org.jboss.jandex.MethodParameterInfo
RecordComponentInfo none3 java.lang.reflect.RecordComponent javax.lang.model.element.RecordComponentElement org.jboss.jandex.RecordComponentInfo
  1. Relies on reflection.
  2. Provides access to the corresponding reflection object.
  3. CDI doesn’t reflect the existence of records yet.
  4. There are types such as java.lang.reflect.GenericDeclaration or java.lang.reflect.AccessibleObject, but nothing directly corresponding,
  5. It is possible to obtain a ClassInfo for package-info.class,

Types are:

Build Compatible Extensions Portable Extensions Reflection Annotation Processing Jandex
Type none1 java.lang.reflect.Type2 javax.lang.model.type.TypeMirror org.jboss.jandex.Type
VoidType none1 java.lang.Class2 javax.lang.model.type.NoType org.jboss.jandex.VoidType
PrimitiveType none1 java.lang.Class2 javax.lang.model.type.PrimitiveType org.jboss.jandex.PrimitiveType
ClassType none1 java.lang.Class2 javax.lang.model.type.DeclaredType org.jboss.jandex.ClassType
ArrayType none1 java.lang.Class, java.lang.reflect.GenericArrayType2 javax.lang.model.type.ArrayType org.jboss.jandex.ArrayType
ParameterizedType none1 java.lang.reflect.ParameterizedType2 javax.lang.model.type.DeclaredType org.jboss.jandex.ParameterizedType
TypeVariable none1 java.lang.reflect.TypeVariable2 javax.lang.model.type.TypeVariable org.jboss.jandex.TypeVariable, org.jboss.jandex.UnresolvedTypeVariable
WildcardType none1 java.lang.reflect.WildcardType2 javax.lang.model.type.WildcardType org.jboss.jandex.WildcardType
  1. Relies on reflection.
  2. There’s also an alternative hierarchy rooted at java.lang.reflect.AnnotatedType.

Conclusion

It’s been a ride, but you’ve seen the most important bits of the Build Compatible Extensions API. You’ve also seen how it compares to other APIs, particularly to Portable Extensions.

By now, you hopefully realize that while there are some necessary differences, you would be able to write an extension just fine. If not, the cdi-dev mailing list is a great place to ask!