A Java 21 annotation processor that automatically injects Assert.notNull() checks into constructors at compile time.
JAssert provides a @NullCheck annotation that, when applied to a class, automatically adds null validation to all constructor parameters. The validation happens at compile time by modifying the bytecode, so there's zero runtime overhead and no reflection.
Before compilation:
@NullCheck
public class User {
private final String name;
private final String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
}After compilation (bytecode equivalent):
public class User {
private final String name;
private final String email;
public User(String name, String email) {
Assert.notNull("User.email", email);
Assert.notNull("User.name", name);
this.name = name;
this.email = email;
}
}If you call new User(null, "test@example.com"), you'll get:
IllegalArgumentException: User.name must not be null
- ✅ Automatic null checks in all constructors
- ✅ Works with Lombok - processes Lombok-generated constructors
- ✅ Compile-time only - no runtime dependencies
- ✅ Zero performance overhead - bytecode modification, not reflection
- ✅ Clear error messages - includes class and field name in exception
jassert/
├── nullcheck-processor/ # The annotation processor implementation
│ ├── @NullCheck # Annotation to mark classes
│ ├── NullCheckProcessor # Bytecode manipulation logic
│ └── Assert # Runtime assertion class
├── nullcheck-parent/ # Parent POM for easy consumer setup
└── demo-app/ # Example consumer application
The easiest way to use @NullCheck is to inherit from the provided parent POM:
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>nullcheck-parent</artifactId>
<version>1.0.1-SNAPSHOT</version>
</parent>
<artifactId>your-project</artifactId>
<dependencies>
<!-- Add your dependencies here -->
<!-- The processor is already configured in parent -->
</dependencies>
</project>That's it! No build configuration needed. The parent POM includes all necessary setup.
Install the parent POM first:
cd nullcheck-parent
mvn installIf you can't change your parent POM (e.g., using Spring Boot parent), you need to add the configuration manually.
<dependency>
<groupId>com.example</groupId>
<artifactId>nullcheck-processor</artifactId>
<version>1.0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>The annotation processor uses javac internals (AST manipulation), which requires special JVM exports:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>21</source>
<target>21</target>
<!-- Required for annotation processors that use javac internals -->
<fork>true</fork>
<compilerArgs>
<!-- Export javac internals to annotation processors -->
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
</compilerArgs>
<!-- Processor order: Lombok first, then NullCheck -->
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
</path>
<path>
<groupId>com.example</groupId>
<artifactId>nullcheck-processor</artifactId>
<version>1.0.1-SNAPSHOT</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>For Spring Boot Projects: Use combine.children="append" to preserve Spring Boot's compiler configuration:
<configuration combine.children="append">
<fork>true</fork>
<compilerArgs combine.children="append">
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<!-- ... other exports ... -->
</compilerArgs>
</configuration>Simply add @NullCheck to any class:
import com.example.annotations.NullCheck;
@NullCheck
public class Product {
private final String id;
private final String name;
private final double price;
public Product(String id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
}JAssert works seamlessly with Lombok. Just ensure Lombok is listed before NullCheck in the annotationProcessorPaths:
import com.example.annotations.NullCheck;
import lombok.RequiredArgsConstructor;
@NullCheck
@RequiredArgsConstructor
public class User {
private final String name;
private final String email;
}The processor will automatically add null checks to Lombok's generated constructor.
- Annotation Processing: During compilation, the
NullCheckProcessorruns after Lombok - AST Modification: It finds all constructors in
@NullCheckannotated classes - Bytecode Injection: For each constructor parameter, it prepends:
Assert.notNull("ClassName.paramName", paramValue);
- Compilation: The modified AST is compiled into bytecode
The processor uses javac's internal APIs (JCTree, TreeMaker) to manipulate the Abstract Syntax Tree before final compilation.
When a null value is passed to a constructor:
@NullCheck
public class Order {
private final String orderId;
private final Customer customer;
public Order(String orderId, Customer customer) {
this.orderId = orderId;
this.customer = customer;
}
}
// Usage:
new Order("12345", null); // throws IllegalArgumentExceptionException:
java.lang.IllegalArgumentException: Order.customer must not be null
at com.example.Assert.notNull(Assert.java:10)
at com.example.demo.Order.<init>(Order.java:8)
at ...
# Build all modules
mvn clean install
# Run tests
mvn test
# Build without tests
mvn clean install -DskipTestsThe demo-app module contains example classes demonstrating different scenarios:
# Compile and test the demo app
mvn -pl demo-app clean test
# The tests verify that null checks are properly injectedCheck these example classes in demo-app/src/main/java/com/example/demo/:
SimpleClass.java- Basic class with manual constructorSimpleClassLombok.java- Class using Lombok's@RequiredArgsConstructorSimpleRecord.java- Java record (processors don't modify records by design)
- Java 21+ (uses modern Java features and javac APIs)
- Maven 3.9+
- maven-compiler-plugin 3.13.0+
- Java 21 Requirement: The processor uses javac 21 internals
- Maven Only: Currently only configured for Maven (Gradle support would need additional configuration)
- JVM Exports Required: Consumers must configure
--add-exportsflags - Records Not Supported: Java records have immutable constructors that can't be modified
- Primitive Types: The processor adds checks for all parameters, but primitives can't be null (this is harmless but unnecessary)
The annotation processor (NullCheckProcessor.java) uses:
javax.annotation.processing.AbstractProcessor- Standard annotation processing APIcom.sun.tools.javac.tree.JCTree- javac's internal AST representationcom.sun.tools.javac.tree.TreeMaker- Factory for creating new AST nodescom.sun.source.util.Trees- Bridge between annotation processing and javac internals
Java's module system (JPMS) hides javac internals by default. The --add-exports flags explicitly export these packages:
jdk.compiler/com.sun.tools.javac.api- Compiler APIjdk.compiler/com.sun.tools.javac.tree- AST classesjdk.compiler/com.sun.tools.javac.util- Utility classesjdk.compiler/com.sun.tools.javac.processing- Processing environment
Without these exports, you'll get:
IllegalAccessError: class NullCheckProcessor cannot access class JavacProcessingEnvironment
The project includes comprehensive tests:
Tests the processor logic in isolation using in-memory compilation:
mvn -pl nullcheck-processor testTests real-world usage scenarios:
mvn -pl demo-app testAll tests verify that:
- Null values throw
IllegalArgumentException - Non-null values are accepted
- Error messages include the correct field names
Symptom: No null checks are injected, tests pass when they shouldn't
Solution:
- Ensure
fork=truein compiler configuration - Verify
annotationProcessorPathsincludes the processor - Check that
--add-exportsflags use-Jprefix:-J--add-exports=...
Symptom:
IllegalAccessError: class NullCheckProcessor cannot access class JavacProcessingEnvironment
Solution: Add all required --add-exports flags with -J prefix
Symptom: Null checks not added to Lombok-generated constructors
Solution: Ensure Lombok is listed before NullCheck in annotationProcessorPaths
Symptom: Classes compiled to wrong directory (e.g., target/classes/MyClass.class instead of target/classes/com/example/MyClass.class)
Solution: Run Maven from the project root, not from a submodule directory
This is an educational project demonstrating annotation processing and bytecode manipulation. Feel free to:
- Report issues
- Submit pull requests
- Use as a reference for your own processors
[Add your license here]
- Uses Lombok for demo examples
- Inspired by Lombok's approach to compile-time code generation
- Built with Java 21 and Maven