Refactoring Our Application With Lombok
This post links to: Building a Spring Boot CRUD App With Postgres from Scratch: The Complete Guide.
This guide follows on from API Documentation with Swagger.
In this post, we'll do some refactoring to our application and help clean up some of our code by using Project Lombok.
What is Lombok
In short: "Project Lombok is a small Java library that helps remove boilerplate code from your classes.".
It's a compile-time annotation processor that helps reduce boilerplate by generating getters, setters, constructors, builders, and other common methods.
How Lombok Works
- Add Lombok annotations to your classes (e.g.
@Getter,@Setter,@Builder). - When the project is compiled, Lombok’s annotation processor runs.
- It modifies the Abstract Syntax Tree (AST) before the bytecode is generated.
- The compiled
.classfiles will contain the real methods that you would've written manually.
So when you read your .java classes in an IDE they'll show fewer methods, but the compiled code behaves as if you wrote all the boilerplate by hand.
Why Lombok
- Less boilerplate, cleaner and more readable code.
- Reduced maintenance. If you add/remove a field, you won't need to manually update methods like getters & setters.
- Encourage better patterns, like the
builderpattern. - Keeps classes clean and focused on business logic rather than repetitive code.
Setting Up Dependencies
// build.gradle
dependencies {
//other deps...
implementation 'org.projectlombok:lombok:1.18.42'
annotationProcessor 'org.projectlombok:lombok:1.18.42'
// other deps...
}
NOTE: We must add the annotationProcessor keyword to ensure the .class files get generated using Lombok annotation processing.
Updating Our User Class With Lombok
Now we have our dependency added for Lombok, let's apply some Lombok magic to our User.java class:
Here's the original class without Lombok, lots of boilerplate:
// User.java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue
@Column(name = "id")
private Long id;
@Column(name = "name")
private String name;
@Column(name = "email")
private String email;
@Column(name = "age")
private int age;
public User(final Long id, final String name, final String email, final int age) {
this.id = id;
this.name = name;
this.email = email;
this.age = age;
}
public User() {}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(final String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(final String email) {
this.email = email;
}
public int getAge() {
return age;
}
public void setAge(final int age) {
this.age = age;
}
}
Now here's the User.java class using Lombok:
// User.java
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue
@Column(name = "id")
private Long id;
@Column(name = "name")
private String name;
@Column(name = "email")
private String email;
@Column(name = "age")
private int age;
}
See how by adding these annotations, we've made our class cleaner and more readable. Now we can clearly see what fields a user has without all the extra boilerplate.
@Getter&@Setter- creates getters and setters for each of the fields for theUser.javaobject.@AllArgsConstructor&@NoArgsConstructor- creates two constructors, one with all class arguments passed in and one with no arguments.- You can also use
@RequiredArgsConstructor- this will generate a constructor that takes arguments for all mandatory fields, such as fields marked asfinalor with an@NonNullannotation.
The @Data Annotation
We can simplify this further by using the @Data annotation. This annotation provides the following boilerplate under one annotation:
@Getter&@Setter@RequiredArgsConstructor@ToString@EqualsAndHashCode
The @Data annotation is great for simple DTOs (Data Transfer Objects) and models like our UserCreateRequest.java model:
// UserCreateRequest.java
@Data
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "Payload to create a new user")
public class UserCreateRequest {
@Schema(description = "Full name of the user", example = "Jon Smith", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
private String name;
@Schema(description = "Email address of the user", example = "jon@example.com", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@Schema(description = "Age of the user", example = "30", requiredMode = Schema.RequiredMode.REQUIRED)
@Min(value = 18, message = "Age should not be less than 18")
@Max(value = 150, message = "Age should not be greater than 150")
private int age;
}
Lombok @Data and JPA @Entity
Something to note is that it's advisable not to use the Lombok @Data annotation alongside the JPA @Entity annotation. This is mainly due to the generated @ToString, @EqualsAndHashCode and setter methods that conflict with JPA’s lazy loading, proxies, and identity rules. You can still use Lombok with JPA by carefully choosing annotations that don't conflict.
@EqualsAndHashCode
The @Data Lombok annotation generates the following:
@Override
public boolean equals(Object o) {
// compares all fields
}
@Override
public int hashCode() {
// uses all fields
}
On the surface this doesn't seem to be a problem. However, JPA uses lazy loading proxies for relationships like @ManyToOne, @OneToMany, etc. Lazy loading essentially means the fields with relationships to other JPA entities are not loaded into memory immediately when referenced, here's more info on lazy loading. Then when the equals() or hashCode() methods tries to access those lazily loaded fields, Hibernate must load them from the database. This can cause a number of issues such as: unexpected SQL queries, cascading loads, huge performance issues, stack overflows and infinite loops for bidirectional relationships.
@ToString
The @ToString Lombok annotation can also cause similar issues to the @EqualsAndHashCode annotation. Again, if the @ToString method uses a field that has a relationship with another JPA entity, it will have to call the database to get that relationship. Worse yet, if the relationship is bidirectional this could cause an infinite loop and/or a stack overflow.
The @Value Annotation
The @Value Lombok annotation creates immutable objects by making all fields private and final. Under the hood it generates getters, a full constructor, and the usual equals(), hashCode(), toString() methods. It’s ideal for DTOs, API responses, and value objects where the data should not change after creation (i.e. immutable data).
Here's an example of using the @Value annotation with the UserResponse.java class:
// UserResponse.java
@Value
@Schema(description = "Response object for a user")
public class UserResponse {
@Schema(description = "Unique ID of the user", example = "1")
Long id;
@Schema(description = "Full name of the user", example = "Jon Smith")
String name;
@Schema(description = "Email address of the user", example = "jon@example.com")
String email;
@Schema(description = "Age of the user", example = "30")
int age;
}
Java Records
However, you can actually achieve the same result as the @Value Lombok annotation by using a more modern Java record (records were released as a non-preview feature in Java 16+).
Here's our updated example using a Java record:
@Schema(description = "Response object for a user")
public record UserResponse(@Schema(description = "Unique ID of the user", example = "1") Long id,
@Schema(description = "Full name of the user", example = "Jon Smith") String name,
@Schema(description = "Email address of the user", example = "jon@example.com") String email,
@Schema(description = "Age of the user", example = "30") int age) {
}
As you can see, you can create a simple DTO with a record that is clean and easily readable. Both records and classes with the @Value annotation achieve the same thing. If you're on Java 16+, records make more sense and are the modern standard.
One potential downfall of records is if you want to provide optional fields when constructing an object. You can achieve this by passing null fields. However, usually the recommended approach is using the builder pattern when trying to build complex objects with optional fields. You can use a builder with a record, but at that point you might want to consider using the @Value annotation alongside the @Builder annotation, which we will talk about now.
The @Builder Annotation
Lombok’s @Builder annotation will automatically generate the builder pattern for your annotated class. In short, the builder pattern allows you to create an object in a readable, step-by-step approach rather than using a constructor. Providing named arguments, optional arguments and also helping to prevent bugs where arguments are passed to a constructor in the wrong order.
So instead of creating our User object like so:
final var user = new User(1L, "John", "John@email.com", 30);
We can use the builder pattern to do something like this:
Adding the @Builder annotation to User class.
// User.java
@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users")
public class User {
// Fields...
}
Using the builder pattern to create a User object.
final var user = User.builder()
.id(1L)
.name("John")
.email("John@email.com")
.age(30)
.build();
As you can see, the builder pattern makes this object construction more readable and explicit. We can see each named argument (id, name etc.) and the associated value that would be assigned on the creation of the object. The build() method is the method to ensure the object is instantiated.
A common potential bug when using a constructor over the builder pattern, involves accidentally mixing up arguments with the same type e.g. if name and email (both String types) were added in the wrong order like so:
// Should be ID, name, email, age.
final var user = new User(1L, "John@email.com", "John", 30);
Or the object gets updated and name and email are actually in a different order. The builder would automatically handle this. Where as, you'd have to remember to update the constructor accordingly.
In the example we've discussed, the User object currently has few arguments and is easier to manage with a constructor. However, if we scale the object up by adding other fields i.e. (address, phone number, password etc.), then it becomes more challenging to handle.
@Builder(toBuilder = true)
Another useful feature of the @Builder annotation is the toBuilder option that can be selected. This lets you create a modified copy of an existing object by creating a builder pre-populated with its current values. For example:
Adding the toBuilder option to the User class.
// User.java
@Builder(toBuilder = true)
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users")
public class User {
// Fields...
}
Creating a modified copy of the User class.
User original = User.builder()
.id(1L)
.username("John")
.email("John@email.com")
.age(30)
.build();
User modifiedCopy = original.toBuilder()
.email("new.John@email.com")
.build();
This is great when you want immutability but also want to derive new objects from old ones. Some example use cases include:
- configuration changes - immutable config objects where only one field changes.
- DTO transformations - modify a field while keeping all others the same.
- creating variants during testing - have a base test object and create slightly modified variants (e.g. a test object with a null field to test how nulls are handled for that field).
@Builder Pitfalls
Lombok’s @Builder annotation is powerful but has several pitfalls: default values aren’t used unless you mark them with @Builder.Default (e.g. initialising empty array lists), validation in setters won’t be triggered (has to be done in the constructor), and builders can cause issues with JPA entities or overloaded constructors. The @Builder annotation also doesn’t integrate smoothly with Java records. Use @Builder cautiously, especially with larger domain models.
The @Slf4j Annotation
One last annotation we will look at from Lombok is @Slf4j. This annotation automatically generates a logger instance for your class using the SLF4J logging API.
If we took our UserService as an example, instead of writing the following for each class that requires logging:
@Service
public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
//...
}
We can use the @Slf4j annotation to achieve the same thing:
@Slf4j
@Service
public class UserService {
//...
}
This reduces the boilerplate while also improving the flexibility by automatically handling any class renames. Also, it uses the standard SLF4J interface keeping the logging framework-agnostic that works with Logback, Log4j2 etc.
Example Usage
To use the logger you can do something like the following example:
@Slf4j
@Service
public class UserService {
//...
public User getUserById(final Long id) {
log.info("Getting user with ID: {}", id);
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found with ID: " + id));
}
//...
}
Logging Best Practices
There is such a thing as too much logging. Used in the right way, logging can provide useful insights into the behaviour of your application. Used wrongly, this can lead to noisy and unhelpful logs that are hard to read and don't actually tell you much about your application.
There are many ways to manage logging and a lot of it will be driven by your application domain and what you or your team deem worthy to log. Not everyone will agree with my points listed below. Even so, I believe these best practices should help guide anyone to have fairly helpful and safe logs.
Parameterised Logging
This is a fairly common best practice. Instead of using string concatenation to log out variables:
log.info("Saving user " + user.getId()); // inefficient
Use parameterised logging:
log.info("Saving user {}", user.getId());
Parameterised logging is not only more performant than string concatenation, but it provides better readability. If we took the following example:
// Parameterised
log.info("Saving user with ID: {}, First Name: {}, Last Name: {}", user.getId(), user.getFirstName(), user.getLastName());
// String Concatenation
log.info("Saving user with ID: " + user.getId() + ", First Name: " + user.getFirstName() + ", Last Name: " + user.getLastName());
The parameterised example is a lot easier to read when there are multiple variables to log out. It's also a lot more flexible and less prone to formatting errors when refactoring the log line.
Use the Right Log Levels
Something that is quite nuanced is getting your log levels right. This is important as it provides a way for you to quickly filter out logs that need attention. Here are the following levels and a rough guide on when to use them:
log.error() - Something in the app failed and needs immediate attention. This usually indicates a core functionality is broken in the app.log.warn() - Something unexpected happened in the app, the app continues to run, but it will still need to be looked at.log.info() - High-level app events (e.g. start-up, shutdown, major operations and functions).log.debug() - Detailed debugging information to help developers diagnose issues and view app flows.log.trace() - Extremely fine-grained logs more detailed than debug (usually disabled).
Here are some example use cases:
log.error("Could not save user {}", userId, e);
log.warn("Incorrect information provided for user {}", userId);
log.info("User {} created successfully", userId);
log.debug("Fetching users from database");
log.trace("Database lookup for user '{}' resulted in a miss", userId);
Avoid Logging Entire Entities
When logging, it's best to try to avoid logging entire entities. Not only may the entity have sensitive/unnecessary information attached, it can potentially cause performance issues. This is especially true if you're retrieving JPA entities that may be using lazy loading for fields with relationships.
So avoid doing something like this:
//...
final User userById = userService.getUserById(id);
log.info("Found user: {}", userById);
//...
This would generate a console output like so:
2025-12-14T09:54:29.583Z INFO 30095 --- [crud.app] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 0 ms
2025-12-14T09:54:30.583Z INFO 23799 --- [crud.app] [nio-8080-exec-2] f.b.dev.crud.app.user.UserController : Found user: User(id=353, name=Jon, email=jon@example.com, age=40)
Either don't add the log if it's not necessary or select the information you want to log out: name, id etc.
Don't Log Sensitive Info
Quite an obvious one, but still worth mentioning, is to not log sensitive information like:
- passwords
- tokens
- credit card numbers
- SSNs
- auth headers
- personal data (addresses, phone numbers etc.)
A more common example of this links to the previous point made about not logging out entire entities.
Let's let's say we added a password to the User model and then did something like this:
//...
final User userById = userService.getUserById(id);
log.info("Found user: {}", userById);
//...
This would result in an output that looks like this:
2025-12-14T09:54:29.583Z INFO 30095 --- [crud.app] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 0 ms
2025-12-14T09:54:30.583Z INFO 23799 --- [crud.app] [nio-8080-exec-2] f.b.dev.crud.app.user.UserController : Found user: User(id=353, name=Jon, email=jon@example.com, age=40, password=password)
The log would display the whole User entity to the console, along with the password of the user.
Again, the solution would be either don't add the log if it's not necessary or select the information you want to log out: name, id etc.
Log The Whole Exception
When logging exceptions, log the whole exception rather than just the message. This will allow you to retain the stacktrace from the original exception that was thrown and logged.
So instead of doing this with just the exception message exposed:
//...
try {
service.doAction();
} catch (Exception e) {
log.error("Error performing action: {}", e.getMessage());
}
//...
Do this instead where the whole exception is exposed including it's stacktrace:
//...
try {
service.doAction();
} catch (Exception e) {
log.error("Error performing action", e);
}
//...
General Rule
A general rule of thumb is to log what’s important, not everything. Logs should tell you when something has gone wrong and aid you in debugging issues without creating too much noise.
What's Next?
So to recap we:
- Found out what Lombok is, how it works and why we should use it
- Added Lombok to our project to help us refactor
- Learnt to use the
@Dataand@Valueannotations alongside using Java records - Used the
@Builderannotation - Implemented logging into our app using the
@Slf4jannotation, along with some best practices
Stay tuned for the upcoming blog in the series about Securing Your API with Spring Security!