Lombok is often presented as a productivity library. Add @Getter, @Builder, or @RequiredArgsConstructor, and you no longer need to write repetitive Java code.

This is true, but it is only part of the story.

Lombok generates code during compilation. Other frameworks then read this code through annotations, parameter names, or reflection. When we use Lombok with Spring, Jackson, Hibernate, or MapStruct, the generated code becomes part of the application.

I found a good example of this with @RequiredArgsConstructor and Spring's @Qualifier. The class compiled and the source code looked correct. However, the application failed when the Spring context started.

This article is not a Lombok tutorial. It focuses on a few small configuration details that can change the behavior of an application.

In an earlier field note, Lombok Annotations Are Design Decisions, Not Just Shortcuts, I looked at how annotations such as @Builder, @Value, and @RequiredArgsConstructor shape a class. Here, I focus on a smaller but less visible detail: how lombok.config keeps framework annotations in generated code.


What the field shows is not what Spring receives

Imagine an application with two implementations of the same interface:

public interface PaymentGateway {
    PaymentResult charge(Payment payment);
}

@Component("internalGateway")
class InternalPaymentGateway implements PaymentGateway {
    // ...
}

@Component("externalGateway")
class ExternalPaymentGateway implements PaymentGateway {
    // ...
}

The service asks for the external implementation:

@Service
@RequiredArgsConstructor
public class SettlementService {

    @Qualifier("externalGateway")
    private final PaymentGateway paymentGateway;
}

The dependency looks complete. The field is final, so Lombok adds it to the generated constructor. The field also has a qualifier.

But without extra configuration, Lombok generates code similar to this:

public SettlementService(PaymentGateway paymentGateway) {
    this.paymentGateway = paymentGateway;
}

The @Qualifier remains on the field, but Spring injects the dependency through the constructor parameter. This parameter has no qualifier.

If two PaymentGateway beans exist and neither one is marked as primary, Spring cannot choose between them. The application fails at startup. We usually see an UnsatisfiedDependencyException caused by a NoUniqueBeanDefinitionException.

This problem is easy to miss. In the source file, the annotation is next to the dependency. At runtime, Spring uses the constructor generated by Lombok.

Spring uses qualifiers to select one bean from several candidates. Lombok controls which field annotations are copied to generated parameters. We need to connect these two behaviors through configuration.


The fix is in lombok.config

The fix needs only two lines:

# lombok.config
config.stopBubbling = true
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

With this configuration, Lombok generates code similar to this:

public SettlementService(
        @Qualifier("externalGateway") PaymentGateway paymentGateway
) {
    this.paymentGateway = paymentGateway;
}

The generated parameter now contains the information that Spring needs.

The full annotation name is important. The += operator is also important because lombok.copyableAnnotations is a list. It adds a new annotation type to this list.

We can use the same rule for a custom qualifier:

lombok.copyableAnnotations += com.acme.platform.region.Region
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface Region {
    String value();
}

We should not copy every annotation. We should copy only annotations that keep the same meaning on a field and on a constructor parameter. Lombok already copies many common nullability annotations. However, it cannot decide that every project wants to copy Spring's @Qualifier.


Configuration inheritance can surprise you

A lombok.config file applies to its directory and all subdirectories. When Lombok processes a Java file, it looks for configuration files in the parent directories. A configuration file close to the Java source has priority.

This means that a file outside a module can change the code generated inside the module.

In a monorepo, this can be useful. One root file can define common rules for all Java services. It can also create confusing results when Lombok finds a parent configuration that was not made for the current project.

This line prevents that problem:

config.stopBubbling = true

When we place it in the project root, Lombok stops searching in higher directories. The build is then easier to reproduce on another computer.

Subdirectories can still change inherited settings. List settings support += and -=, and clear removes a value inherited from a parent file:

# Do not allow @SneakyThrows in this module
lombok.sneakyThrows.flagUsage = error

# Remove an annotation copied by the parent configuration
lombok.copyableAnnotations -= com.acme.platform.LegacyMarker

For this reason, I see lombok.config as part of the build, not as an IDE preference. It should be stored in Git, and configuration changes should be reviewed like code changes.


What “required” really means

@RequiredArgsConstructor does not create a parameter for every field. It also does not mean exactly “all final fields.” Lombok adds:

  • final fields that have no initial value;
  • fields marked with a supported @NonNull annotation that have no initial value.

An initialized final field is not included:

@RequiredArgsConstructor
class ImportService {
    private final Clock clock = Clock.systemUTC();
    private final ImportRepository repository;
}

The generated constructor receives only repository.

The order also matters. Constructor parameters follow the order of the fields in the class. If we change the field order, we also change the generated constructor. Dependency injection often hides this detail, but tests, manual object creation, and tools based on reflection can depend on it.

Two more details are useful:

  1. A supported @NonNull annotation can make a non-final field required. Lombok can also add a runtime null check to the constructor.
  2. An explicit constructor does not stop Lombok from generating a constructor requested by @RequiredArgsConstructor, @AllArgsConstructor, or @NoArgsConstructor. If two constructors have the same signature, compilation fails.

The annotation name does not explain all these rules. Sometimes we need to inspect the generated code.


onConstructor_ does not copy parameter annotations

Lombok provides an onConstructor_ option:

@RequiredArgsConstructor(onConstructor_ = @Autowired)
class SettlementService {
    // ...
}

This adds an annotation to the generated constructor. It does not copy a qualifier from a field to one constructor parameter. It therefore does not fix the problem in our example.

This option is also part of Lombok's experimental onX feature. In a modern Spring application, a class with one constructor usually does not need @Autowired. The important annotation in this example is the qualifier on the parameter, so copyableAnnotations is the correct setting.


Look at the generated code with delombok

When generated code works with a framework, the original source file does not always show enough information. Lombok includes a tool named delombok. It converts Lombok code into normal Java source.

For one class, we can print the result with:

java -jar lombok.jar delombok -p \
  src/main/java/com/acme/payment/SettlementService.java

For a complete source directory:

java -jar lombok.jar delombok src/main/java \
  -d target/delombok

The result answers useful questions:

  • Is the qualifier present on the generated parameter?
  • Which fields are included in the constructor?
  • What is the parameter order?
  • Where are the null checks?
  • Which annotations were copied to generated methods?

We can also inspect the Lombok configuration used for a source file:

# Show the active configuration for a source file
java -jar lombok.jar config \
  src/main/java/com/acme/payment/SettlementService.java

# List the configuration keys supported by this Lombok version
java -jar lombok.jar config -g --verbose

I now use delombok whenever the code created by Lombok does not behave as I expect with another framework.


Test the real Spring wiring

A normal unit test can miss this problem. The test may create SettlementService directly or replace the dependency with a mock. The missing qualifier becomes visible only when Spring creates the real dependency graph.

A small context test can detect the problem:

@SpringBootTest
class ApplicationWiringTest {

    @Test
    void contextLoads() {
        // If the context starts, the wiring is valid.
    }
}

For a large application, we can create a smaller Spring test with only the related beans. This is faster and makes the purpose of the test clearer. The important point is to let Spring use the generated constructor.

It is also useful to keep a fixed Lombok version in the build and to review every change to lombok.config. Lombok runs during compilation. A dependency or configuration update can therefore change the generated Java code.


The lesson

Lombok was not broken, and Spring did not behave in a random way. Each tool followed its own rules:

  • Lombok generated a constructor for the required field.
  • Lombok did not copy a Spring annotation by default.
  • Spring tried to resolve the generated parameter by type.
  • Two beans matched, but the parameter had no qualifier.

The problem came from the difference between the source code I could see and the constructor that Spring used.

This is why lombok.config is important. It does more than change accessor style or the logger field name. It controls how annotations and other details move into generated code.

My rule is simple: when Lombok-generated code is used by another framework, inspect the generated result at least once. Two lines in lombok.config can prevent a failure when the application starts.

References