View source on GitHub

Module 38 - Lombok & MapStruct

Two annotation-processing tools that eliminate boilerplate without runtime cost: Lombok - generates getters, setters, constructors, builders, and loggers at compile time; MapStruct - generates type-safe DTO ↔ entity mapping code at compile time.

Both tools produce plain Java source files in target/generated-sources/ - no reflection, no proxies, no startup overhead.


Annotation Processor Setup

<!-- pom.xml: Lombok MUST be listed before mapstruct-processor -->
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <version>${lombok.version}</version>
            </path>
            <path>
                <groupId>org.mapstruct</groupId>
                <artifactId>mapstruct-processor</artifactId>
                <version>1.5.5.Final</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>
  Why order matters:
  MapStruct calls Lombok-generated getters and setters.
  If MapStruct runs first, those methods don't exist yet → compilation error.
  Lombok must generate its accessors before MapStruct reads them.

Lombok Annotations

@Data

@Data   // shorthand for: @Getter + @Setter + @ToString +
        //                @EqualsAndHashCode + @RequiredArgsConstructor
public class Address {
    private String street;
    private String city;
    private String country;
}

// Generated:
//   getStreet(), setStreet(), ... for every field
//   toString() → "Address(street=..., city=..., country=...)"
//   equals() + hashCode() using ALL fields
//   Address() - no-args constructor (no final/required fields → empty constructor)

@Builder

@Builder
public class User {
    private Long id;
    private String firstName;
    private String email;
}

// Usage:
User user = User.builder()
        .id(1L)
        .firstName("Alice")
        .email("alice@example.com")
        .build();

// Generated: UserBuilder inner class with method chaining + build()

@Builder.Default - Required for Field Initializers

// PITFALL: without @Builder.Default, field initializers are IGNORED by the builder
@Builder
public class Task {
    private List<String> tags = new ArrayList<>();  // WARNING: builder ignores this
}
Task.builder().build().getTags();  // → null (not empty list!)

// FIX: @Builder.Default preserves the initializer
@Builder
public class Task {
    @Builder.Default
    private List<String> tags = new ArrayList<>();  // builder uses this
}
Task.builder().build().getTags();  // → [] (empty list, as expected)

@Builder + @NoArgsConstructor + @AllArgsConstructor

// PITFALL: @Builder generates a package-private all-args constructor,
// removing Java's implicit no-args constructor.
// → Jackson deserialization fails, JPA proxy creation fails, new Foo() fails.

// FIX: declare both explicitly
@Data
@Builder
@NoArgsConstructor   // restores no-args constructor
@AllArgsConstructor  // required by @Builder - provides the all-args constructor
public class CreateUserRequest {
    private String firstName;
    private String lastName;
    private String email;
}

// Now both work:
new CreateUserRequest()                            // Jackson/JPA
CreateUserRequest.builder().email("x").build()    // fluent construction

@Value - Immutable Value Object

@Value  // all fields: private final, no setters, all-args constructor, equals, hashCode, toString
@Builder
public class Product {
    Long id;
    String name;
    BigDecimal price;
    String category;
}

// setName() does not exist - compile error if called
// equals/hashCode: based on all fields (structural equality)
Product p1 = Product.builder().id(1L).name("Laptop").price(new BigDecimal("999")).build();
Product p2 = Product.builder().id(1L).name("Laptop").price(new BigDecimal("999")).build();
p1.equals(p2);  // → true (same data)

@Slf4j + @RequiredArgsConstructor

@Service
@Slf4j                    // generates: private static final Logger log = LoggerFactory.getLogger(...)
@RequiredArgsConstructor  // generates constructor for every final or @NonNull field
public class NotificationService {

    private final EmailGateway emailGateway;  // FINAL → in @RequiredArgsConstructor constructor
    private int sentCount = 0;                // non-final → NOT in constructor

    public String send(String recipient, String message) {
        log.info("Sending to {}: {}", recipient, message);  // log is available immediately
        sentCount++;
        return emailGateway.deliver(recipient, message);
    }
}

// Spring uses the single constructor (no @Autowired needed):
// new NotificationService(emailGateway)

Fine-Grained vs @Data for Entities

// Prefer fine-grained annotations on entities:
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = "address")             // avoid verbose/circular output
@EqualsAndHashCode(of = {"id", "email"})   // only stable identity fields
public class User {
    private Long id;
    private String firstName;
    private String email;
    private Address address;
}

// Avoid @Data on JPA entities because:
//   @Data's @EqualsAndHashCode includes ALL fields
//   → lazy collection access in JPA sessions
//   → two unsaved entities (id = null) are equal even if they represent different rows
//   → toString traverses all relationships → LazyInitializationException outside session

Lombok Pitfalls

1. Mutable EqualsAndHashCode Breaks Collections

  Object added to HashSet → stored in bucket based on hashCode() at that moment.
  If any field used in hashCode() is mutated → new hash → different bucket.
  HashSet.contains(object) → looks in WRONG bucket → returns false.
  The object is "lost" - still in the set but unreachable.

  Fix: @EqualsAndHashCode(of = "id") - use only the stable identity field.
  For JPA entities: use only @Id in equals/hashCode.
set.contains(key)  // before mutation → true
key.setEmail("new@example.com");
set.contains(key)  // after mutation  → false! (bucket lookup fails)
set.size()         // → 1 (object still there, just unreachable)

2. @Builder.Default Required for Initializers

  @Builder ignores field initializers unless @Builder.Default is present.
  builder().build().getTags() → null (not the expected empty list).

3. @Builder Removes No-Args Constructor

  @Builder alone: no new Foo() possible → Jackson fails, JPA fails.
  Fix: always pair with @NoArgsConstructor + @AllArgsConstructor.

4. @ToString Circular Reference

  A → @ToString includes B → B @ToString includes A → StackOverflowError.
  Fix: @ToString(exclude = "backReference") on the owning side.

MapStruct

Why MapStruct Instead of Manual Mapping?

  Manual mapping              MapStruct
  ─────────────────────────   ────────────────────────────────────────────
  user.getFirstName() +       @Mapping(expression = "java(...)")
    " " + user.getLastName()  → generated once, used everywhere
  Written per-method          Interface with method signatures only
  No null checks              MapStruct generates null-safe nested paths
  Runtime reflection (Dozer)  Compile-time code generation (no reflection)
  Easy to miss a field        Unmapped fields produce warnings by default

@Mapper Setup

// componentModel = "spring": MapStruct generates @Component on the impl class
// Spring auto-discovers it → inject via @Autowired or constructor
@Mapper(componentModel = "spring")
public interface UserMapper {
    // methods...
}

// Generated: UserMapperImpl.java in target/generated-sources/annotations/
// Inspect it - it's plain Java code, fully readable

Mapping Patterns

@Mapper(componentModel = "spring")
public interface UserMapper {

    // 1. Ignore a field (never mapped from source)
    @Mapping(target = "id", ignore = true)
    User requestToUser(CreateUserRequest request);

    // 2. Expression: combine two fields into one
    @Mapping(target = "fullName",
             expression = "java(user.getFirstName() + \" \" + user.getLastName())")
    UserDto userToDto(User user);

    // 3. Nested source: flatten address.city → city
    //    MapStruct generates a null-safe path:
    //    if (user.getAddress() != null) dto.setCity(user.getAddress().getCity());
    @Mapping(target = "city", source = "address.city")
    UserDto userToDto(User user);

    // 4. List mapping: auto-generated from the single-item method
    List<UserDto> usersToDtos(List<User> users);

    // 5. Partial update (PATCH): null source fields don't overwrite target
    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    void updateFromRequest(CreateUserRequest request, @MappingTarget User user);
}

Partial Update - PATCH Semantics

  Traditional PUT: send all fields, overwrite everything.
  PATCH: send only changed fields; null means "leave unchanged".

  @BeanMapping(nullValuePropertyMappingStrategy = IGNORE) + @MappingTarget:
    source.email = "new@example.com" → user.email = "new@example.com" (updated)
    source.firstName = null          → user.firstName unchanged             (skipped)
    source.role = null               → user.role unchanged                  (skipped)

  Use case: REST PATCH endpoint where clients send partial updates.

Inspecting Generated Code

  After mvn compile, open:
  target/generated-sources/annotations/com/example/UserMapperImpl.java

  You'll see:
    @Component   ← because componentModel = "spring"
    public class UserMapperImpl implements UserMapper {
        @Override
        public UserDto userToDto(User user) {
            if (user == null) { return null; }      ← null-safe
            String city = null;
            if (user.getAddress() != null) {         ← null-safe nested path
                city = user.getAddress().getCity();
            }
            ...
        }
    }

Module 38 - What Was Built

  module-38-lombok-mapstruct/
  ├── pom.xml     (Spring Boot 3.3.5, lombok, mapstruct 1.5.5.Final,
  │               compiler plugin with ordered annotationProcessorPaths)
  └── src/
      ├── main/java/com/javatraining/lombokstruct/
      │   ├── LombokStructApplication.java
      │   ├── entity/
      │   │   ├── User.java        - fine-grained: @Getter @Setter @Builder @ToString(exclude)
      │   │   │                      @EqualsAndHashCode(of=...) @NoArgsConstructor @AllArgsConstructor
      │   │   ├── Address.java     - @Data @Builder @NoArgsConstructor @AllArgsConstructor
      │   │   └── Product.java     - @Value @Builder (immutable)
      │   ├── service/
      │   │   ├── NotificationService.java  - @Slf4j @RequiredArgsConstructor
      │   │   └── EmailGateway.java         - @Slf4j @Component
      │   ├── dto/
      │   │   ├── UserDto.java              - @Data @Builder (fullName, city flattened)
      │   │   └── CreateUserRequest.java    - @Data @Builder @NoArgsConstructor @AllArgsConstructor
      │   └── mapper/
      │       └── UserMapper.java           - @Mapper(componentModel="spring"),
      │                                       ignore, expression, nested source, list, partial update
      └── test/java/com/javatraining/lombokstruct/
          ├── LombokAnnotationsTest.java  9 tests - @Builder, @Data, @Value, @Slf4j, @RequiredArgsConstructor
          ├── LombokPitfallsTest.java     5 tests - mutable HashSet, @Builder.Default, no-args constructor,
          │                                         circular @ToString
          └── MapStructMappingTest.java   9 tests - requestToUser (ignore fields), userToDto (expression,
                                                    nested source, null-safe), list mapping, partial update

All tests: 23 passing (requires Java 21; MapStruct processor is incompatible with Java 25+).


Key Takeaways

  Processor order  Lombok before mapstruct-processor - MapStruct calls Lombok getters

  @Data            All-in-one for simple POJOs; avoid on JPA entities
  @Builder         Fluent construction; requires @NoArgsConstructor + @AllArgsConstructor
                   if a no-args constructor is also needed (Jackson, JPA)
  @Builder.Default Required for field initializers - without it, builder sets null
  @Value           Immutable value object: all final, no setters, structural equals
  @Slf4j           Injects private static final Logger log = LoggerFactory.getLogger(...)
  @RequiredArgsConstructor  Constructor for final/@NonNull fields; non-final fields excluded
  @EqualsAndHashCode(of = "id")  Use only stable fields; mutable fields break HashSets

  MapStruct        Compile-time mapping: no reflection, no runtime cost
  ignore = true    Field never mapped from source (e.g. server-assigned id)
  expression       Java code inline in the annotation; combine fields, call methods
  source = "a.b"   Null-safe nested path: MapStruct generates null checks
  List<DTO>        Auto-generated from single-item mapper method
  IGNORE strategy  Null source fields skip the target field - PATCH semantics
  @MappingTarget   Write into existing object instead of creating a new one