A zero-reflection Java annotation processor that generates fluent, type-safe builders for classes and records — with Jackson support, immutable copy-on-write with, conditional logic, and collection helpers. A lightweight Lombok alternative.
- What is Simple Builders?
- How Simple Builders compares
- Features
- Requirements
- Installation
- Usage
- Examples
- Contributing
- License
- Acknowledgements
- Links
Simple Builders is a Java annotation processor that generates type-safe, fluent builders for existing Java classes and records at compile time. It supports Jackson integration, immutable copy-with updates, conditional logic, and collection helpers, with no runtime reflection.
Simple Builders generates fluent, type-safe builders for your existing classes and records using standard JSR-269 annotation processing — it adds separate, readable generated source and never modifies your types. The main alternatives solve overlapping but different problems, and each is the better choice in its own niche:
- Lombok — the closest "add a builder to my existing class" tool, but it works very differently: rather than generating separate source, it mutates your class at compile time through the compiler's internal, non-public AST APIs. That steps outside the standard model (a normal annotation processor may only add new files, not alter existing ones), so it needs an IDE plugin, hides the generated code behind
delombok, and — because it depends on internal compiler APIs — generally needs a Lombok update for each new JDK before your project compiles (e.g. JDK 16's strong encapsulation, JEP 396, broke it until v1.18.20, a pattern that recurs for JDK 17, 21, and 23). Lombok is also a broad toolkit (@Data,@Value,@SneakyThrows,@Delegate,val, and more) whose implicit behavior can be misused by less-experienced developers — e.g.@Data/@EqualsAndHashCodeon JPA entities (broken equality, lazy-loading pitfalls) or@SneakyThrowsbypassing checked exceptions. Choose Lombok if you're already invested in it for broad boilerplate reduction; choose Simple Builders for one focused capability with explicit, readable source. - Immutables / Google AutoValue / FreeBuilder — value-type generators: you declare an abstract class or interface and they generate an immutable implementation plus a builder. Great when you want to define new immutable value types; less suited when you just want a builder for classes or records you already have and don't want to restructure your model.
- RecordBuilder — focused, excellent builders and
withmethods for records. Choose it if you use records exclusively.
Use Simple Builders when you want fluent, type-safe builders for the classes and records you already have, generated as plain readable source, with no bytecode manipulation and no IDE plugin — and no lock-in: because the builders are ordinary generated Java, you can drop the dependency at any time by copying the generated builder classes into your own sources, and they keep working.
- Required fields: Primitive fields and fields annotated with an annotation named
NotNullorNonNullare non-nullable; constructor parameters are builder inputs.build()enforces the required/non-null contract withIllegalStateException(configuration details). - Copy /
with/toBuilder: The generatedWithinterface providesinstance.with(b -> ...)for copy-and-modify andinstance.with()for a builder pre-populated from the instance (generateWithInterface). - Collection immutability: The target type owns the collection contract. Simple Builders passes through what the type stores; use defensive copying such as
List.copyOf(...)in the type when the result must be immutable (configuration details). - Incremental / singular collection API:
add2Xhelpers,ArrayList/HashSet/HashMapcollection builders, and varargs helpers cover incremental collection construction (collection helper options). - Inheritance: Inherited setters are discovered, and constructors exposed by the annotated subclass are used. A final superclass field not exposed by that constructor is intentionally not bypassed (configuration options).
Value semantics (equals, hashCode, toString) and generating brand-new immutable value types are deliberate out-of-scope paradigm choices, not missing builder features.
- Low Runtime Dependencies: The generated code has only dependencies to a core dependency for CollectionBuilders and to Apache.CommonLang3
- Type-Safe Builders: Compile-time type checking for all builder methods
- Fluent API: Clean, chainable API for object construction
- Collections Support: Built-in support for collections and maps
- Annotation Preservation: Validation annotations are automatically copied to builder methods
- With Interface Pattern: Type-safe object modifications using generated With interfaces
- Jackson Support: Supporting Jackson deserialization via
@JsonPOJOBuilderand optional generation ofSimpleModules (one per package) (both need to be enabled) - JavaDoc Usage Examples: Generated builder methods include auto-generated usage examples in their JavaDoc (per-method fluent snippets plus a class-level example), so IDE tooltips show exactly how to use each builder
- Java 17 or later
- Maven 3.8+ (for building from source)
For Maven-based projects, add the following to your POM file in order to use Simple Builders (the dependencies are available at Maven Central):
...
<properties>
<simple-builders.version>0.2.0</simple-builders.version>
</properties>
...
<dependencies>
<dependency>
<groupId>io.github.java-helpers</groupId>
<artifactId>simple-builders-core</artifactId>
<version>${simple-builders.version}</version>
</dependency>
</dependencies>
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
<annotationProcessorPaths>
<path>
<groupId>io.github.java-helpers</groupId>
<artifactId>simple-builders-processor</artifactId>
<version>${simple-builders.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
...If you don't work with a dependency management tool, you can obtain a distribution bundle from Releases page.
Annotate your class with @SimpleBuilder to generate a builder:
import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
@SimpleBuilder
public class Person {
private String name;
private int age;
private List<String> emailAddresses;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getAge() { return age; }
public void setAge(int age) { this.age = age; }
public List<String> getEmailAddresses() { return emailAddresses; }
public void setEmailAddresses(List<String> emailAddresses) {
this.emailAddresses = emailAddresses;
}
}Use the generated builder:
Person person = PersonBuilder.create()
.name("John Doe")
.age(30)
.emailAddresses("john@example.com", "j.doe@example.com")
.add2EmailAddresses("jane@example.com")
.build();Simple Builders preserves validation annotations on your builder methods:
import jakarta.validation.constraints.*;
@SimpleBuilder
public class User {
private String email;
private int age;
public String getEmail() { return email; }
public void setEmail(@Email @NotNull String email) {
this.email = email;
}
public int getAge() { return age; }
public void setAge(@Min(18) int age) {
this.age = age;
}
}The generated builder preserves these annotations:
User user = UserBuilder.create()
.email("user@example.com") // @Email and @NotNull are on the parameter
.age(25) // @Min(18) is on the parameter
.build();This ensures validation frameworks work seamlessly with builder-generated objects.
Simple Builders treats a field as non-nullable when its type is primitive or its parameter carries
an annotation whose simple name is NotNull or NonNull, regardless of the annotation package.
The name is matched without requiring a particular validation framework.
For non-nullable constructor fields, build() requires the builder value to be set and non-null.
For non-nullable setter fields, the field remains optional, but a value supplied to the builder must
be non-null. Violations throw IllegalStateException.
For example, both the primitive price and the @NotNull name below are required — omitting
either makes build() fail:
@SimpleBuilder
public record Product(@NotNull String name, double price) {}
ProductBuilder.create()
.name("Keyboard")
.build(); // throws IllegalStateException: price must be set
ProductBuilder.create()
.price(99.0)
.build(); // throws IllegalStateException: name must be setThe NotNull/NonNull simple-name check is framework-agnostic; use the annotation type already
used by your project.
Specify default values for fields that are applied when not explicitly set before build():
import org.javahelpers.simple.builders.core.annotations.Default;
@SimpleBuilder
public record Product(
String name,
double price,
@Default("GENERAL") String category,
@Default("true") boolean active
) {}
// category defaults to "GENERAL", active defaults to true
Product product = ProductBuilder.create()
.name("Laptop")
.price(1500.0)
.build();
// product.category() == "GENERAL"
// product.active() == true
// Explicit values override defaults
Product custom = ProductBuilder.create()
.name("Widget")
.price(9.99)
.category("ACCESSORIES")
.active(false)
.build();
// custom.category() == "ACCESSORIES"The @Default annotation works on both constructor parameters (records) and fields (classes with setters). The value() is a string expression interpreted based on the field type:
- String — wrapped in double quotes automatically (e.g.
@Default("GENERAL")generates"GENERAL") - char — wrapped in single quotes (e.g.
@Default("A")generates'A') - numeric/boolean primitives — used as-is (e.g.
@Default("0.0")generates0.0) - complex types — used as a raw Java expression (e.g.
@Default("List.of()")generatesList.of())
Framework-agnostic detection: The processor also detects annotations named Default or DefaultValue from any package (e.g. Jakarta REST @DefaultValue) if they have a String value() member.
Interaction with non-null checks: A field with a @Default is never considered "required" — even if annotated with @NotNull, no validation error is raised when the field is unset.
Apply builder modifications conditionally using the conditional() method:
int age = 45;
Person person = PersonBuilder.create()
.name("Jane Doe")
.conditional(
() -> age >= 18,
p -> p.role("ADULT"),
p -> p.role("MINOR"))
.build();For simple conditions without an else case, use the two-parameter overload:
PersonBuilder.create()
.name("John Doe")
.conditional(() -> isPremiumUser, p -> p.discountRate(0.15))
.build();Simple Builders provides special handling for collections and nested objects:
@SimpleBuilder
public class Project {
private String name;
private List<Task> tasks;
private Map<String, String> metadata;
private ProjectStatus status;
// Getters and setters...
}
@SimpleBuilder
public class Task {
private String title;
private String description;
private boolean completed;
// Getters and setters...
}
public enum ProjectStatus {
PLANNING, IN_PROGRESS, COMPLETED, ON_HOLD
}Usage with collections and nested builders:
String version = "1.0.0";
Project project = ProjectBuilder.create()
.name("Simple Builders in version '%s' with a bit of complexity", version)
.status(ProjectStatus.IN_PROGRESS)
.tasks(tasks -> tasks
.add(taskBuilder -> taskBuilder
.title("Implement core functionality")
.completed(true)
)
.add(taskBuilder -> taskBuilder
.title("Add documentation")
.description("Update README and add Javadocs")
)
)
.metadata(metadata -> metadata
.put("version", "1.0.0")
.put("owner", "dev-team"))
.build();Immutability of collections in the built object is the target type's responsibility. Simple Builders stores exactly what the target type stores and does not silently wrap collections, so the target type's collection contract remains intact. For a record, enforce that contract in its canonical or compact constructor:
@SimpleBuilder
public record Basket(List<String> items) {
public Basket {
items = List.copyOf(items);
}
}The builder passes its collection value to Basket; the record makes the stored collection
unmodifiable.
Simple Builders generates a nested With interface for each builder field, enabling a clean, type-safe way to create modified copies of objects. This pattern is particularly useful for creating variations of an object:
Person person = PersonBuilder.create()
.name("John Doe")
.age(30)
.build();
// Create a modified copy using the With interface
Person olderPerson = PersonBuilder.create()
.with(person)
.age(31) // Only change the age
.build();
// By implementing the With interface, you can create modified copies of objects in a type-safe way
Person youngerPerson = person.with(p -> p.age(29));The With interface provides type-safe setter methods that mirror the builder's API, making it easy to create object variations without manually copying all fields.
Simple Builders provides extensive configuration options to customize the generated builder code. You can control:
- Field setter generation (Supplier, Consumer, Builder patterns)
- Conditional logic helpers
- Access modifiers for builders and methods
- Collection helper methods
- Integration features
Configuration can be applied per-class using @SimpleBuilder.Options annotation or project-wide using compiler options.
All configuration options are available as compiler arguments using the -A flag. For example:
javac -Asimplebuilder.verbose=true \
-Asimplebuilder.generateFieldSupplier=false \
YourClass.javaOr in Maven:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-Asimplebuilder.verbose=true</arg>
<arg>-Asimplebuilder.generateFieldSupplier=false</arg>
</compilerArgs>
</configuration>
</plugin>📋 For a complete list of all available compiler arguments, see CompilerArgumentsEnum.
📖 For complete documentation, examples, and all available options, see the Configuration Guide.
The example module contains real-world examples demonstrating various builder configurations and features. You can explore the source DTOs and their generated builders:
A comprehensive example showcasing all fundamental Java property types with a minimal, setter-only builder configuration:
- Source DTO:
BookDto.java- Demonstrates all primitive types, collections, Optional, BigDecimal, date/time types, and nested objects - Custom Annotation:
@ElementaryBuilder- A template annotation that disables all advanced features (suppliers, consumers, collection builders, With interface, @Generated annotation) - Generated Builder:
BookDtoBuilder.java- Clean, minimal builder with only setter methods - Tests:
BookDtoBuilderTest.java- Usage examples
Examples with all builder features enabled:
- Person DTO:
PersonDto.javaandPersonDtoBuilder.java- Demonstrates nested objects, collections, suppliers, conditional logic, and various setter patterns- Usage Examples:
PersonDtoBuilderTest.java- Shows supplier methods, collection builders, nested builder consumers, and conditional logic
- Usage Examples:
- Product Record:
ProductRecord.javaandProductRecordBuilder.java- Java Record support with full builder features and With interface pattern- Usage Examples:
ProductRecordTest.java- Comprehensive tests demonstrating With interface for immutable Records, fluent modifications, and custom with methods
- Usage Examples:
Examples demonstrating special annotations and nested object relationships:
- Sponsor DTO:
SponsorDto.javaandSponsorDtoBuilder.java- Simple DTO used as nested object in other examples - Mannschaft DTO:
MannschaftDto.javaandMannschaftDtoBuilder.java- Demonstrates@IgnoreInBuilderannotation to exclude specific setter methods from the generated builder, plus Set collections with nested objects - Default Values:
ProductWithDefaults.java(record) andOrderWithDefaults.java(class) - Demonstrate@Defaultannotation for unset builder fields
These examples serve as both documentation and integration tests for the annotation processor.
Contributions are welcome! Please see CONTRIBUTING.md for:
- Development setup and project structure
- Building and testing strategies (important for annotation processor modules)
- Debugging with verbose output
- Code style and formatting
- Pull request process
For maintainers, see RELEASE.md for the release process.
For maintenance guarantees, the project's bus-factor, and a vendoring/fork strategy for high-reliability adopters, see GOVERNANCE.md.
This project is licensed under the MIT License - see the LICENSE file for details.
This project was made possible thanks to the following:
- Benji Weber - The With interface pattern is inspired by Benji's innovative work on functional builders and extending Java Records.
- RecordBuilder by Randall Hauch - A state-of-the-art builder solution for Java records. If your project uses records exclusively, RecordBuilder is an excellent choice. Simple Builders extends these concepts to traditional Java classes.
- Roaster - A fluent Java source generation and formatting library from the JBoss Forge ecosystem. Roaster is used to generate and format the builder source code.
- Google Compile Testing - Essential for testing annotation processors with comprehensive compilation diagnostics.
The following resources were invaluable for understanding annotation processing:
- Baeldung: Java Annotation Processing and Creating a Builder - Comprehensive guide to annotation processing fundamentals
- Roaster GitHub Repository - Reference for Java source generation and formatting with Roaster
- Annotation Processing Demo by Le Dung - Hands-on examples of annotation processor implementation
Thank you to all contributors and the Java community for making this project possible!