Lombok for Cleaner POJO Code

8 min read

The previous three lessons built POJOs the long way — explicit constructors, getters, setters, and the occasional equals/hashCode. That's roughly thirty lines of ceremony for a five-field model, repeated for every class. Lombok is a small annotation library that generates all of that at compile time. One annotation produces every getter and setter; another produces a fluent builder. The Java code shrinks, the wire format stays identical, and Jackson doesn't notice the difference. This lesson is the small set of Lombok annotations that earn their keep in a Rest Assured suite.

Adding Lombok to the project

The dependency goes in pom.xml with <scope>provided</scope> — Lombok runs at compile time, not in production:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.32</version>
    <scope>provided</scope>
</dependency>

For IntelliJ: install the official Lombok plugin (Settings → Plugins → search "Lombok"). For VS Code: install the Lombok Annotations Support for VS Code extension. Eclipse needs a one-time lombok.jar -runInstaller step. The plugin teaches the IDE that @Data-annotated classes have getters and setters at compile time, even though the source file doesn't show them. Without it, the IDE shows phantom errors on every user.getName() call.

Before Lombok

A representative POJO from the previous lesson, in full:

package com.mycompany.apitests.models.response;
 
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.Objects;
 
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserResponse {
    private int id;
    private String name;
    private String email;
    private String role;
    private String createdAt;
 
    public UserResponse() {}
    public UserResponse(int id, String name, String email, String role, String createdAt) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.role = role;
        this.createdAt = createdAt;
    }
 
    public int getId()              { return id; }
    public void setId(int id)       { this.id = id; }
    public String getName()         { return name; }
    public void setName(String n)   { this.name = n; }
    public String getEmail()        { return email; }
    public void setEmail(String e)  { this.email = e; }
    public String getRole()         { return role; }
    public void setRole(String r)   { this.role = r; }
    public String getCreatedAt()    { return createdAt; }
    public void setCreatedAt(String c) { this.createdAt = c; }
 
    @Override public String toString() {
        return "UserResponse{id=" + id + ", name='" + name + "', ...}";
    }
    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserResponse u)) return false;
        return id == u.id && Objects.equals(name, u.name)
            && Objects.equals(email, u.email);   // ... etc
    }
    @Override public int hashCode() {
        return Objects.hash(id, name, email, role, createdAt);
    }
}

Five fields, ~30 lines of pure ceremony. None of it is interesting; all of it is required for Jackson, debugging, and assertEquals to behave.

After Lombok

Same class, same wire format:

package com.mycompany.apitests.models.response;
 
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
 
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserResponse {
    private int id;
    private String name;
    private String email;
    private String role;
    private String createdAt;
}

Five fields, five lines of fields, three Lombok annotations. Compile this and the resulting .class file has every getter, every setter, an empty constructor, an all-args constructor, toString, equals, and hashCode — exactly the previous version, byte for byte.

The annotations worth knowing

  • @Data — the everything bagel. Generates getters for every field, setters for every non-final field, a toString, equals, and hashCode. Use on POJOs that act like data containers. (Most of yours.)
  • @Getter / @Setter — only one direction. Use when you want immutable-ish models (getters but no setters) or only the one direction. Can be applied per-field, too.
  • @NoArgsConstructorpublic UserResponse() {}. Required by Jackson for deserialisation; pair with @AllArgsConstructor for tests that build instances inline.
  • @AllArgsConstructor — constructor taking every field. Convenient for new UserResponse(1, "Alice", "alice@test.com", ...) in tests.
  • @RequiredArgsConstructor — constructor taking every final and @NonNull field. Useful when only some fields are required at construction.
  • @ToString — only toString (without the rest of @Data). When you want the debug output but not the equals/hashCode. @ToString(exclude = "password") to keep secrets out of logs.
  • @EqualsAndHashCode — equality without setters/getters. Same exclude option works.

The @Data + @NoArgsConstructor + @AllArgsConstructor trio covers ~80% of test POJOs. Reach for the more surgical annotations only when @Data's defaults don't fit.

@Builder — the test data superpower

@Builder generates a fluent builder for the class:

import lombok.Builder;
import lombok.Data;
 
@Data
@Builder
public class CreateUserRequest {
    private String name;
    private String email;
 
    @Builder.Default
    private String role = "tester";
 
    @Builder.Default
    private boolean active = true;
}

Usage:

CreateUserRequest user = CreateUserRequest.builder()
    .name("Alice")
    .email("alice@test.com")
    .role("admin")
    .build();
 
// Defaults kick in when fields are omitted
CreateUserRequest viewer = CreateUserRequest.builder()
    .name("Bob")
    .email("bob@test.com")
    .build();   // role = "tester", active = true via @Builder.Default

Why this matters in tests: most test data has a few "interesting" fields and a sea of default-able ones. Builders let each test name only the fields that matter to it, leaving the rest implicit. A randomUser() factory method becomes one line:

public static CreateUserRequest randomUser() {
    return CreateUserRequest.builder()
        .name("Test User " + UUID.randomUUID())
        .email("test+" + UUID.randomUUID() + "@test.com")
        .build();
}

@Builder.Default is the small detail that catches many newcomers — without it, default values on fields are ignored by the builder. Always pair the two.

Lombok and Jackson — quietly compatible

Jackson serialises and deserialises via getters and setters, the bean convention. Lombok generates getters and setters that conform to the same convention. The two have no idea about each other; they cooperate transparently:

@Data @NoArgsConstructor @AllArgsConstructor
public class User {
    private String name;
    @JsonProperty("email_address")
    private String email;
    @JsonIgnore
    private String internalNotes;
}

All Jackson annotations work as before. Lombok produces the methods Jackson reads. There's no extra wiring.

A complete request POJO with Lombok

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UpdateUserRequest {
    private String name;
    private String email;
    private String role;
}

Eight lines. Equivalent to ~40 lines without Lombok, identical on the wire, identical to Jackson, identical to call sites that use .builder(). The improvement compounds: a project with 20 model classes saves hundreds of lines of mechanical code.

Lines saved at a glance

A 5-field POJO — same Jackson behaviour, very different source

Without Lombok

  • ~30 lines for 5 fields

  • Manual getters / setters

  • Manual constructors (no-args + all-args)

  • Manual toString / equals / hashCode

  • Boilerplate scales linearly with field count

  • Adding a field = editing 4-5 places

With Lombok

  • ~10 lines for 5 fields

  • @Data: getters + setters + toString + equals + hashCode

  • @NoArgsConstructor + @AllArgsConstructor

  • @Builder: fluent test data construction

  • Source size constant per class

  • Adding a field = adding the field

Beyond line count: the Lombok version is easier to review. A change that adds a field is one line of diff, not five. A change that renames a field is one rename, not five.

A note for TypeScript-trained eyes

TypeScript for QA does this without help — interface User { name: string; email: string; } is the entire equivalent. Java's verbosity is real and Lombok exists because the language doesn't have first-class data types. (Java records, introduced in Java 14, do something similar — but they're immutable, which collides with Jackson's setter-driven deserialisation. For request/response POJOs, Lombok still wins.)

⚠️ Common mistakes

  • Forgetting the IDE plugin. Without it, the IDE flags every user.getName() as a missing method. Tests compile and pass on the command line, but the editor experience becomes unusable. Install the plugin for every IDE the team uses.
  • Forgetting @Builder.Default. A field initialised inline (private String role = "tester") is ignored by @Builder. Without @Builder.Default, the generated builder leaves it null. Add the annotation to every default-bearing field.
  • Using @Data on a class with circular references. @Data generates toString, equals, and hashCode that recurse into every field. A class graph with a cycle (User → Order → User) blows the stack. Either annotate one side with @ToString.Exclude and @EqualsAndHashCode.Exclude, or break the cycle.

🎯 Practice task

Convert your existing POJOs to Lombok and feel the line-count difference. 20–30 minutes.

  1. Add the Lombok dependency to pom.xml (<scope>provided</scope>). Install the IDE plugin. Run mvn clean compile and confirm the build succeeds.
  2. Pick the largest POJO from Lesson 3 (UserResponse, probably). Replace the constructors, getters, setters, toString, equals, hashCode with @Data @NoArgsConstructor @AllArgsConstructor. Run the tests against it — they should all pass without changes.
  3. Use @Builder for a test factory. Annotate CreateUserRequest with @Data @Builder. Write a static helper TestData.randomUser() returning CreateUserRequest.builder().name("...").email("...").build(). Rewrite three tests to use it.
  4. @Builder.Default. Add a default role = "tester" field on CreateUserRequest. Build a request without setting role and confirm via .log().body() that "role": "tester" is on the wire.
  5. Selective annotations. Take an ErrorResponse model. Use @Getter only (no setters — error responses are immutable from the test's perspective). Confirm Jackson can still deserialise into it. (It can — Jackson's setterless deserialisation finds the constructor.)
  6. @ToString.Exclude on a sensitive field. Add a password field to a request POJO and annotate with @ToString.Exclude. Print an instance — confirm the password is omitted from the output. This prevents secrets in logs.
  7. Diff the LOC. Run git diff --stat on the model package. Note the negative number — the same behaviour, fewer lines.
  8. Stretch: add @With to a model. It generates a withName(...) method that returns a copy of the object with one field changed. Useful for parameterised tests: baseUser.withRole("admin") produces a variant without mutating the original.

That's serialisation covered. Chapter 6 turns to framework architecture — how to lift the patterns from the last six chapters into a base test class, shared specs, filters, and helpers that scale across an entire test suite.

// tip to track lessons you complete and pick up where you left off across devices.