Skip to content

Annotation Processors — Compile-Time Validation

Overview and Motivation

Tentackle wires much of itself together reflectively. A domain implementation is instantiated by the framework through a (PersistentDomainObject) constructor; a ValueTranslator through (FxComponent); a GuiProvider through (PersistentDomainObject). None of these contracts can be expressed in Java's type system — an annotation cannot demand a constructor signature.

Left unchecked, a missing constructor is a runtime failure, raised deep inside a factory, at the moment some user first opens some view, in a stack trace that names the framework rather than your class. The apt packages exist to move that failure to where it belongs: a compile error, on the offending class, with a message that says exactly which constructor is missing — and, because annotation processors run inside javac, underlined in the IDE as you type.

MyThingDomainImpl.java:42: error: class MyThingDomainImpl needs constructor (PersistentDomainObject)

This is the correctness-first principle applied to the build: if a contract can be checked early, check it early.

What they do not do

The processors generate nothing. Not one of them touches a Filer. This distinguishes them from the framework's other two build-time mechanisms, which are easy to confuse with them:

Mechanism Runs in Produces
Annotation processors (apt packages) javac Nothing. Errors and warnings only.
@Analyze handlers (tentackle-maven-plugin) The analyze build phase The META-INF service files that wire the application together. See services.md.
Wurblets (wurbelizer-maven-plugin) The generate-sources phase Java source — interfaces, implementations, queries.

A caveat on naming, since it is the source of most confusion here: the analyze phase is itself implemented as a javax annotation processor — AnalyzeProcessor, declared @SupportedAnnotationTypes("*"). But it is instantiated directly by the Maven plugin and run in its own pass, rather than being discovered by javac, and it is not part of the apt packages. So "the @Service annotation processor generates META-INF/services/..." is true of that processor, and never of the ones described here.

The two build-time mechanisms are nevertheless interlocked, in a way that is worth understanding: the @Analyze pipeline is what registers and configures the annotation processors. That loop is described below.


Where They Live

Every module that defines annotations carrying a contract also defines the processor that enforces it:

Package Module Processors
org.tentackle.apt tentackle-core The shared base, plus the four interception processors.
org.tentackle.apt.visitor tentackle-core Reusable type visitors.
org.tentackle.apt.pdo tentackle-pdo Domain/persistent object and operation services.
org.tentackle.session.apt tentackle-session Master serial event services.
org.tentackle.fx.apt tentackle-fx Controllers, value translators, table cell types.
org.tentackle.fx.rdc.apt tentackle-fx-rdc GUI providers, context menu items.

How a Processor Gets Registered

A processor is marked with a single annotation:

@SupportedAnnotationTypes("org.tentackle.pdo.DomainObjectService")
@AnnotationProcessor
public class DomainObjectServiceAnnotationProcessor extends AbstractDomainServiceAnnotationProcessor {

@AnnotationProcessor is itself meta-annotated:

@Service(Processor.class)
public @interface AnnotationProcessor { }

So the processor is simply a service of type javax.annotation.processing.Processor. The @Analyze pipeline writes it into META-INF/services/javax.annotation.processing.Processor, and javac — which discovers processors through exactly that file — picks it up automatically in every downstream module. There is no processor path to configure and no plugin declaration to maintain: putting @AnnotationProcessor on the class is the whole registration.

This is the interlock. The service mechanism registers the processors; the processors then validate the classes that the service mechanism will wire.


The Service Processors

Most of the processors validate a @...Service annotation, and nearly all extend AbstractServiceAnnotationProcessor. The base class contributes the mechanics and one universal rule — a service implementation may not be abstract — and offers three checks to its subclasses:

Method Checks
verifyImplements(element, className) The class is assignable to the named type (erased, so generics don't interfere).
verifyConstructor(element, visitor) Some public constructor matches the visitor.
acceptTypeVisitor(mirror, class) A type matches, resolving type variables to their upper bound — so a declaration like T extends PersistentDomainObject<T> still matches PersistentDomainObject.

The subclass then overrides processClass() and states its contract declaratively:

@Override
protected void processClass(Element element) {
  super.processClass(element);
  if (!verifyConstructor(element, noArgsVisitor)) {
    processingEnv.getMessager().printMessage(
        Diagnostic.Kind.ERROR, "class " + element + " needs a no-args constructor", element);
  }
  if (!verifyConstructor(element, pdoVisitor)) {
    processingEnv.getMessager().printMessage(
        Diagnostic.Kind.ERROR, "class " + element + " needs constructor (PersistentDomainObject)", element);
  }
  verifyImplements(element, "org.tentackle.pdo.DomainObject");
  verifyStatelessDomainLogic(element);
}

Note that each check reports independently rather than returning early: a class missing two constructors is told about both in one compile.

The visitors

The apt.visitor package holds the reusable predicates:

Contracts needing more than one parameter declare a small inline SimpleTypeVisitor8 in the processor itself — as PersistentObjectServiceAnnotationProcessor does for (PersistentDomainObject, DomainContext) and (PersistentDomainObject, Session).

What each processor enforces

Annotation Must implement Must provide constructors
@DomainObjectService DomainObject (), (PersistentDomainObject)
@PersistentObjectService PersistentObject (), (PersistentDomainObject), (PersistentDomainObject, DomainContext), (PersistentDomainObject, Session)
@DomainOperationService DomainOperation (), (Operation)
@PersistentOperationService PersistentOperation (), (Operation), (Operation, DomainContext), (Operation, Session)
@MasterSerialEventService java.util.function.Consumer ()
@FxControllerService FxController () or (ResourceBundle) — see below
@ValueTranslatorService ValueTranslator (FxComponent) or (FxComponent, Class)
@TableCellTypeService TableCellType ()
@GuiProviderService GuiProvider (PersistentDomainObject)
@PdoTableContextMenuItemService PdoTableContextMenuItem (PdoTableCell)
@PdoTreeContextMenuItemService PdoTreeContextMenuItem (PdoTreeCell)

FxControllerServiceAnnotationProcessor is the only one whose requirement depends on the annotation's own values: a controller that declares no FXML url and does declare resources is instantiated programmatically with its bundle, and so must offer (ResourceBundle); otherwise it is loaded by FXML and needs ().


The Interception Processors

Interception is validated by four processors with a different shape, because they check annotations rather than implementations.

InterceptionAnnotationProcessor validates the @Interception meta-annotation itself: that it is applied to an annotation type, and that at most one of implementedBy, implementedByName or implementedByService is given — three ways to name the interceptor, of which exactly one may win.

The other three enforce where an interception annotation may be applied. They share AbstractInterceptorAnnotationProcessor and differ only by the Interception.Type passed to the constructor:

Processor Type An annotated method must be declared in
PublicInterceptorAnnotationProcessor PUBLIC an Interceptable interface
HiddenInterceptorAnnotationProcessor HIDDEN a class implementing Interceptable
AllInterceptorAnnotationProcessor ALL either

The check walks the enclosing type's interfaces and superclasses looking for Interceptable, so inherited implementations are accepted. HIDDEN additionally rejects application to an interface method with a distinct message — the mistake being easy to make and its consequence (an interceptor that silently never fires) being hard to diagnose.

The dynamically discovered annotation set

These three processors carry no @SupportedAnnotationTypes. They cannot: the set of interception annotations is open-ended, and applications define their own. Instead they build it at startup:

@Override
public Set<String> getSupportedAnnotationTypes() {
  return annotationTypes;      // loaded in the constructor
}

loadAnnotationTypes() reads every META-INF/services/<the processor's own class name> on the classpath — for example META-INF/services/org.tentackle.apt.PublicInterceptorAnnotationProcessor — and treats each line as an annotation type to police. (It reads the files directly rather than via ServiceFinder, which is not available to the compiler.)

Those files are written by InterceptionAnalyzeHandler, because @Interception is declared @Analyze("org.tentackle.buildsupport.InterceptionAnalyzeHandler"). The handler reads the annotation's type and appends the annotation's name to the file named after the corresponding processor — defaulting to ALL when no type is given.

So the full loop, for @Transaction in tentackle-pdo:

@Interception(type = PUBLIC)  on  @Transaction
        │  analyze phase — InterceptionAnalyzeHandler
META-INF/services/org.tentackle.apt.PublicInterceptorAnnotationProcessor
        │     └─ contains: org.tentackle.pdo.Transaction
        │  javac in a downstream module
PublicInterceptorAnnotationProcessor.loadAnnotationTypes()
every @Transaction is checked to sit on an Interceptable interface method

Declaring a new interception annotation therefore needs no change to any processor — @Interception on it is enough, and the next build teaches the processor about it.


The Stateless Domain Logic Warning

One processor check is a warning rather than an error, and it encodes a design rule rather than a mechanical contract. AbstractDomainServiceAnnotationProcessor.verifyStatelessDomainLogic() warns about every non-static field in a domain implementation:

warning: PDO domain logic should be stateless! Consider making 'cachedTotal'
effectively immutable and annotate it with @SuppressWarnings("stateful-domain-logic").

Domain implementations back PDOs that are shared, cached and serialized between tiers; mutable instance state in one is a bug waiting for a concurrent reader. Where the field really is safe — effectively immutable, computed once — the warning is suppressed with the constant Constants.SUPPRESS_WARNINGS_STATEFUL_DOMAIN_LOGIC:

@SuppressWarnings("stateful-domain-logic")
private final Money cachedTotal;

The annotation works on the individual field or on the whole class. It applies only to @DomainObjectService and @DomainOperationService; persistent implementations hold the entity's attributes and are expected to have fields.


The Chicken-and-Egg Rule

A module cannot run annotation processors that it is itself compiling — the classes do not exist yet when javac starts. Every module defining processors therefore compiles with them switched off:

<compilerArgs>
  <!-- proc:none because of chicken-egg problem with annotation processors in same module -->
  <arg>-proc:none</arg>
</compilerArgs>

This applies to tentackle-core, tentackle-pdo, tentackle-session, tentackle-fx and tentackle-fx-rdc — exactly the five modules in the table above. Everything downstream, including every application module, gets the processors automatically.

The same problem affects the generated service files, so the few that a processor-defining module needs for itself are hand-maintained under src/main/resources rather than generated. They carry a comment saying so:

# tentackle-pdo is compiled with proc:none due to chicken-egg
org.tentackle.pdo.Transaction
org.tentackle.pdo.NoTransaction
org.tentackle.security.Secured
org.tentackle.pdo.WithinContext
org.tentackle.pdo.NotWithinContext

This is worth knowing before adding an interception annotation to one of those five modules: the analyze phase will not pick it up, and the corresponding src/main/resources/META-INF/services/org.tentackle.apt.*InterceptorAnnotationProcessor file must be edited by hand. In any other module, the annotation alone suffices.


Adding Your Own

To validate an application-specific service annotation:

  1. Extend AbstractServiceAnnotationProcessor.
  2. Annotate with @SupportedAnnotationTypes("...") and @AnnotationProcessor.
  3. Override processClass(), call super, and state the contract with verifyConstructor(...) / verifyImplements(...).
  4. Report through processingEnv.getMessager()Kind.ERROR for a broken contract, Kind.WARNING for a suspicious but legal one.

The processor must live in a module other than the one whose classes it checks.


Summary

Concern How the processors address it
Reflective contracts Constructor and interface requirements that Java cannot express are checked by javac instead of failing at first use.
Diagnosis The error names the class and the exact missing constructor, in the IDE, on the right line.
Registration @AnnotationProcessor is @Service(Processor.class); the analyze phase writes the Processor service file and javac finds it.
Extensibility Interception processors load their annotation set from META-INF, so new annotations need no processor change.
Design rules Stateful domain logic warns rather than errors, and is suppressible where genuinely safe.
Bootstrapping The five processor-defining modules compile -proc:none and hand-maintain their own service files.

They produce no output — which is exactly the point. A successful build means every annotated class already satisfies the contract the framework will rely on at runtime.

See also