How Java annotations generate source code at compile time
Java annotations can do far more than document classes or enable runtime reflection. With an annotation processor, they can inspect source declarations during compilation and create new Java files automatically. This approach reduces repetitive code while keeping the generated result visible, testable and compatible with ordinary build tools.
The basic workflow is straightforward: define an annotation, write a processor that recognises it, and generate source code through the compiler’s processing API. The generated classes are then compiled alongside the rest of the application, so developers can use them as normal Java types.
This technique is useful for API clients, DTO mappers, repository helpers, configuration metadata and type-safe registries. It suits teams building Spring applications in Sydney, Melbourne or Brisbane, where clear build pipelines and maintainable integrations matter as much as development speed.
Understand compile-time annotation processing
An annotation processor implements javax.annotation.processing.Processor, usually by extending AbstractProcessor. It receives elements from the compiler, such as classes, methods and fields, and can inspect their names, modifiers, types and annotation values.
A small annotation might look like this:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface GenerateBuilder {
}
RetentionPolicy.SOURCE is sufficient when the annotation only controls compilation. The processor reads the annotated class and writes a new file using Filer, commonly with JavaPoet. Because processing happens during javac, the output belongs in the generated-sources directory rather than being created when the application starts.
Create an annotation processor
The processor needs to declare the annotation types it supports and validate the annotated elements. A simplified implementation can generate a companion class:
@SupportedAnnotationTypes("com.example.GenerateBuilder")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public final class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
for (Element element :
roundEnv.getElementsAnnotatedWith(GenerateBuilder.class)) {
TypeElement type = (TypeElement) element;
String name = type.getSimpleName() + "Builder";
// Use JavaPoet and processingEnv.getFiler() here
}
return true;
}
}
Register the processor under META-INF/services/javax.annotation.processing.Processor. Modern Maven and Gradle builds can also configure it through an annotation processor dependency. Keeping the processor in a separate module prevents accidental dependency cycles and makes it easier to publish internally.
Generated code should have predictable names and packages. A processor for an Australian payments platform, for example, might create request builders for integrations used by merchants in Melbourne while leaving the original domain classes unchanged. Clear diagnostics from Messager are essential when a field has an unsupported type or a required annotation value is missing.
Choose the right generation strategy
Source generation is usually preferable to runtime reflection when the output is stable and performance matters. The compiler catches missing methods and incompatible types, while IDEs can index generated classes for navigation and autocomplete. Runtime proxies still have a place for dynamic behaviour, but they can hide errors until an endpoint or code path is exercised.
| Approach | Best use | Main advantage | Typical limitation |
|---|---|---|---|
| Annotation processor | Builders, mappers and clients | Type-safe generated Java | Requires build configuration |
| Reflection | Flexible metadata-driven features | Little generated code | Errors appear at runtime |
| Runtime proxy | Dynamic interfaces and remote calls | Quick integration model | Debugging can be less direct |
| Template generation | Large structured source files | Full control over output | More maintenance effort |
A generated API client can also wrap an external mobile service while keeping vendor-specific details behind an interface. For example, a team assessing a mobile app resource should isolate that external dependency from business logic, then generate only the stable request and response types required by the application.
Integrate with Maven and Gradle
In Maven, place the processor on the annotationProcessorPaths configuration or use the compiler plugin’s processor path. The application module should depend on the annotation definitions, while the processor implementation remains available only during compilation. This separation keeps the production classpath small.
Gradle projects generally use compileOnly for the annotation library and annotationProcessor for the implementation. Run a clean build when changing generated output, because stale classes can make a successful incremental build appear correct. Australian teams working across Sydney and Perth can also benefit from committing processor tests and locking Java versions in CI, avoiding differences between local machines and hosted runners.
Spring users can generate components, converters or client factories, but generated classes should follow normal dependency-injection conventions. If a generated class needs a service, produce a constructor and annotate it appropriately rather than relying on hidden field injection.
Test and maintain generated code
Processor tests should compile small fixture classes and assert that the expected source is produced. Google Compile Testing is useful for checking generated files, compiler errors and diagnostic messages without starting a complete application. Test cases should cover duplicate names, nested classes, generic fields and invalid annotation combinations.
Treat generated source as an API. Document whether developers may edit it, place it under a clearly named build directory, and make output deterministic so source-control diffs remain meaningful. Generated code should also preserve nullability, visibility and package conventions used by the project.
For applications serving Australia’s local market, generated clients can standardise integrations with banks, logistics providers or GST-aware billing services, while hand-written adapters retain control over authentication and error handling. The result is less boilerplate without surrendering design decisions to a code-generation tool.