Using MapStruct with Project Lombok
3 CommentsMapStruct and Project Lombok are two tools which can make your life as a developer much easier.
MapStruct is a slick project which generates type mappers via simple interfaces. You define an interface method to accept a source type with a return type. And MapStruct generates the implementation of the mapper.
Project Lombok is a tool which helps eliminate a lot of ceremonial / boilerplate code from your Java code. For example, you can define a POJO with several properties, then use Lombok Annotations to enhance the class with Getters, Setters, Constructors, and Builders. Project Lombok is a time saver and helps to de-clutter your code.
MapStruct will use getters and setters for its mappers. MapStruct will even utilize Lombok generated builders. This is a recent addition to MapStruct, and a real nice touch.
Both of these projects utilize annotation processing at compile time to work their magic. This gives them a hook into the Java compile process to enhance the source code being compiled.
While great for performance, it does cause us a delicate dance at compile time.
If MapStruct is going to use a Lombok generated Builder or Lombok generated Setter, what if Lombok has not run yet? Obviously, the compile would fail.
A workaround to this used to be placing all your Lombok enhanced POJOs into a separate module. This will force Lombok to process before MapStruct, solving our compile time problem.
But, this is a kludgey. With more recent versions of MapStruct and Project Lombok this work around is no longer needed.
In this post, I will show you how to configure Maven to support the annotation processing needs to using MapStruct with Project Lombok.
Project Overview
For the context of this project, let’s say we are developing a Spring Boot Microservice to make ACH Payments.
There is nothing new about ACH Payments. This standard has been around for over 40 years.
However, a number of banks are now exposing REST style APIs to make ACH Payments. One implementation is by Silicone Valley Bank. You can read their ACH API documentation here.
So, our hypothetical Spring Boot Microservice will be receiving an instruction to make a ACH Payment. We wish to accept the instruction, persist it to the database, call the ACH API, update the database with the result.
Project Lombok POJOs
Our example will have 3 POJOs:
- An inbound ‘make payment message’
- A Hibernate Entity for persistence
- An ACH API Entity for calling the REST style API
Three different POJOs which are ideal candidates for Project Lombok.
Possibly a 4th POJO for handing the API response. However, the SVB Bank documentation (above) that we are following, uses the same payload for the response.
MapStruct Mappers
Our example project has 3 different POJOs, each containing simular data.
New developers often complain about this. And ask, can’t we just use one type?
Short answer, is no. In our use case, we are writing the microservice. We don’t necessarily have control of the inbound message. We do have control of the Hibernate POJO. However, we definitely do not have control of the 3rd party ACH API.
We will need the following mappers:
- Inbound make payment message to Hibernate Entity (POJO)
- Hibernate POJO to ACH API type
- Update Hibernate POJO from ACH API type
MapStruct and Project Lombok Spring Boot Project
In this section of the post, we will implement the data models discussed in the previous section, which entails setting up Maven dependencies, configuring Maven’s annotation processing, creating POJOs with Project Lombok annotations, and implementing MapStruct mappers.
Complete source code for this post is available on GitHub.
Maven Configuration
For the purposes of this post, we will setup a very simple Spring Boot project. If you’re creating a project using Spring Initializr you will need the following dependencies:
- Webflux (or Spring MVC)
- Spring Data JPA
- Validation
- Project Lombok
Initial Maven Dependencies
You should have the following dependencies in your Maven POM.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>
MapStruct Dependency
You’ll need to add the MapStruct dependency to the Maven POM. (At the time of writing, MapStruct is not an option in Spring Initializr.)
I recommend defining the version in a Maven property.
<properties> <java.version>11</java.version> <org.mapstruct.version>1.4.1.Final</org.mapstruct.version> </properties> . . . (code omitted for brevity) <dependency> <groupId>org.mapstruct</groupId> <artifactId>mapstruct</artifactId> <version>${org.mapstruct.version}</version> </dependency>
You can view the complete Maven POM on GitHub here.
Maven Annotation Processing Configuration
The Maven Compiler plugin needs to be configured to support the annotation processors of MapStruct and Project Lombok. The versions should match the project dependencies. Hence putting the MapStruct version in a Maven Property. We will utilize the Project Lombok version inherited from the Spring Boot Parent POM.
You need to add the following to the build / plugins
section of the POM.
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.0</version> <configuration> <annotationProcessorPaths> <path> <groupId>org.mapstruct</groupId> <artifactId>mapstruct-processor</artifactId> <version>${org.mapstruct.version}</version> </path> <path> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </path> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok-mapstruct-binding</artifactId> <version>0.2.0</version> </dependency> </annotationProcessorPaths> <compilerArgs> <compilerArg> -Amapstruct.defaultComponentModel=spring </compilerArg> </compilerArgs> </configuration> </plugin>
MapStruct Spring Configuration
A nice feature of MapStruct is the ability to optionally annotate the mappers with the Spring @Component
stereotype. With this option enabled, the generated mappers will be available for dependency injection as Spring Beans.
The following snippet enables the Spring annotation. You can omit this from your configuration if you are not using Spring.
This is also shown in the Maven Compiler configuration above.
<compilerArgs> <compilerArg> -Amapstruct.defaultComponentModel=spring </compilerArg> </compilerArgs>
Java POJOs
For our example, we need to define 3 Java POJOs.
IDE Configuration for Project Lombok
When working with Project Lombok you’ll need to be sure to enable annotation processing in your IDE compiler settings.
Also, you should install a Project Lombok plugin. Details for IntelliJ are here. Instructions for other IDEs are available under the ‘install’ menu option.
Send Payment POJO
The Send Payment POJO represents a send payment message.
In our use case example, we are developing a microservice which listens for a message to send a payment. This POJO represents the message payload we are expecting.
SendPayment
Below is a Java POJO annotated with Project Lombok annotations.
In this example I’m using the @Data
annotation, which generates Getters, Setters, toString, equals, and hash code.
Two additional annotations are present to generate constructors for no arguments, and all arguments.
@Data @NoArgsConstructor @AllArgsConstructor public class SendPayment { private UUID paymentId; private UUID payeeId; private String payoutMemo; private Long amount; private String payeeFirstName; private String payeeLastName; private String payeeAddressLine1; private String payeeAddressCity; private String payeeAddressStateOrProv; private String payeeAddressZipOrPostal; private String payeeAddressCountryCode; private String routingNumber; private String accountNumber; private String accountName; }
You can see the Java POJO is free of a lot of code that you normally would need to write.
If you wish to see the actual POJO produced, run the Maven compile goal and inspect the target/classes/<pacakge>
folder.
Here is the POJO that is generated via Project Lombok.
Notice all the code you did not write!
public class SendPayment { private UUID paymentId; private UUID payeeId; private String payoutMemo; private Long amount; private String payeeFirstName; private String payeeLastName; private String payeeAddressLine1; private String payeeAddressCity; private String payeeAddressStateOrProv; private String payeeAddressZipOrPostal; private String payeeAddressCountryCode; private String routingNumber; private String accountNumber; private String accountName; public UUID getPaymentId() { return this.paymentId; } public UUID getPayeeId() { return this.payeeId; } public String getPayoutMemo() { return this.payoutMemo; } public Long getAmount() { return this.amount; } public String getPayeeFirstName() { return this.payeeFirstName; } public String getPayeeLastName() { return this.payeeLastName; } public String getPayeeAddressLine1() { return this.payeeAddressLine1; } public String getPayeeAddressCity() { return this.payeeAddressCity; } public String getPayeeAddressStateOrProv() { return this.payeeAddressStateOrProv; } public String getPayeeAddressZipOrPostal() { return this.payeeAddressZipOrPostal; } public String getPayeeAddressCountryCode() { return this.payeeAddressCountryCode; } public String getRoutingNumber() { return this.routingNumber; } public String getAccountNumber() { return this.accountNumber; } public String getAccountName() { return this.accountName; } public void setPaymentId(final UUID paymentId) { this.paymentId = paymentId; } public void setPayeeId(final UUID payeeId) { this.payeeId = payeeId; } public void setPayoutMemo(final String payoutMemo) { this.payoutMemo = payoutMemo; } public void setAmount(final Long amount) { this.amount = amount; } public void setPayeeFirstName(final String payeeFirstName) { this.payeeFirstName = payeeFirstName; } public void setPayeeLastName(final String payeeLastName) { this.payeeLastName = payeeLastName; } public void setPayeeAddressLine1(final String payeeAddressLine1) { this.payeeAddressLine1 = payeeAddressLine1; } public void setPayeeAddressCity(final String payeeAddressCity) { this.payeeAddressCity = payeeAddressCity; } public void setPayeeAddressStateOrProv(final String payeeAddressStateOrProv) { this.payeeAddressStateOrProv = payeeAddressStateOrProv; } public void setPayeeAddressZipOrPostal(final String payeeAddressZipOrPostal) { this.payeeAddressZipOrPostal = payeeAddressZipOrPostal; } public void setPayeeAddressCountryCode(final String payeeAddressCountryCode) { this.payeeAddressCountryCode = payeeAddressCountryCode; } public void setRoutingNumber(final String routingNumber) { this.routingNumber = routingNumber; } public void setAccountNumber(final String accountNumber) { this.accountNumber = accountNumber; } public void setAccountName(final String accountName) { this.accountName = accountName; } public boolean equals(final Object o) { if (o == this) { return true; } else if (!(o instanceof SendPayment)) { return false; } else { SendPayment other = (SendPayment)o; if (!other.canEqual(this)) { return false; } else { Object this$amount = this.getAmount(); Object other$amount = other.getAmount(); if (this$amount == null) { if (other$amount != null) { return false; } } else if (!this$amount.equals(other$amount)) { return false; } Object this$paymentId = this.getPaymentId(); Object other$paymentId = other.getPaymentId(); if (this$paymentId == null) { if (other$paymentId != null) { return false; } } else if (!this$paymentId.equals(other$paymentId)) { return false; } Object this$payeeId = this.getPayeeId(); Object other$payeeId = other.getPayeeId(); if (this$payeeId == null) { if (other$payeeId != null) { return false; } } else if (!this$payeeId.equals(other$payeeId)) { return false; } label158: { Object this$payoutMemo = this.getPayoutMemo(); Object other$payoutMemo = other.getPayoutMemo(); if (this$payoutMemo == null) { if (other$payoutMemo == null) { break label158; } } else if (this$payoutMemo.equals(other$payoutMemo)) { break label158; } return false; } label151: { Object this$payeeFirstName = this.getPayeeFirstName(); Object other$payeeFirstName = other.getPayeeFirstName(); if (this$payeeFirstName == null) { if (other$payeeFirstName == null) { break label151; } } else if (this$payeeFirstName.equals(other$payeeFirstName)) { break label151; } return false; } Object this$payeeLastName = this.getPayeeLastName(); Object other$payeeLastName = other.getPayeeLastName(); if (this$payeeLastName == null) { if (other$payeeLastName != null) { return false; } } else if (!this$payeeLastName.equals(other$payeeLastName)) { return false; } label137: { Object this$payeeAddressLine1 = this.getPayeeAddressLine1(); Object other$payeeAddressLine1 = other.getPayeeAddressLine1(); if (this$payeeAddressLine1 == null) { if (other$payeeAddressLine1 == null) { break label137; } } else if (this$payeeAddressLine1.equals(other$payeeAddressLine1)) { break label137; } return false; } label130: { Object this$payeeAddressCity = this.getPayeeAddressCity(); Object other$payeeAddressCity = other.getPayeeAddressCity(); if (this$payeeAddressCity == null) { if (other$payeeAddressCity == null) { break label130; } } else if (this$payeeAddressCity.equals(other$payeeAddressCity)) { break label130; } return false; } Object this$payeeAddressStateOrProv = this.getPayeeAddressStateOrProv(); Object other$payeeAddressStateOrProv = other.getPayeeAddressStateOrProv(); if (this$payeeAddressStateOrProv == null) { if (other$payeeAddressStateOrProv != null) { return false; } } else if (!this$payeeAddressStateOrProv.equals(other$payeeAddressStateOrProv)) { return false; } Object this$payeeAddressZipOrPostal = this.getPayeeAddressZipOrPostal(); Object other$payeeAddressZipOrPostal = other.getPayeeAddressZipOrPostal(); if (this$payeeAddressZipOrPostal == null) { if (other$payeeAddressZipOrPostal != null) { return false; } } else if (!this$payeeAddressZipOrPostal.equals(other$payeeAddressZipOrPostal)) { return false; } label109: { Object this$payeeAddressCountryCode = this.getPayeeAddressCountryCode(); Object other$payeeAddressCountryCode = other.getPayeeAddressCountryCode(); if (this$payeeAddressCountryCode == null) { if (other$payeeAddressCountryCode == null) { break label109; } } else if (this$payeeAddressCountryCode.equals(other$payeeAddressCountryCode)) { break label109; } return false; } label102: { Object this$routingNumber = this.getRoutingNumber(); Object other$routingNumber = other.getRoutingNumber(); if (this$routingNumber == null) { if (other$routingNumber == null) { break label102; } } else if (this$routingNumber.equals(other$routingNumber)) { break label102; } return false; } Object this$accountNumber = this.getAccountNumber(); Object other$accountNumber = other.getAccountNumber(); if (this$accountNumber == null) { if (other$accountNumber != null) { return false; } } else if (!this$accountNumber.equals(other$accountNumber)) { return false; } Object this$accountName = this.getAccountName(); Object other$accountName = other.getAccountName(); if (this$accountName == null) { if (other$accountName != null) { return false; } } else if (!this$accountName.equals(other$accountName)) { return false; } return true; } } } protected boolean canEqual(final Object other) { return other instanceof SendPayment; } public int hashCode() { int PRIME = true; int result = 1; Object $amount = this.getAmount(); int result = result * 59 + ($amount == null ? 43 : $amount.hashCode()); Object $paymentId = this.getPaymentId(); result = result * 59 + ($paymentId == null ? 43 : $paymentId.hashCode()); Object $payeeId = this.getPayeeId(); result = result * 59 + ($payeeId == null ? 43 : $payeeId.hashCode()); Object $payoutMemo = this.getPayoutMemo(); result = result * 59 + ($payoutMemo == null ? 43 : $payoutMemo.hashCode()); Object $payeeFirstName = this.getPayeeFirstName(); result = result * 59 + ($payeeFirstName == null ? 43 : $payeeFirstName.hashCode()); Object $payeeLastName = this.getPayeeLastName(); result = result * 59 + ($payeeLastName == null ? 43 : $payeeLastName.hashCode()); Object $payeeAddressLine1 = this.getPayeeAddressLine1(); result = result * 59 + ($payeeAddressLine1 == null ? 43 : $payeeAddressLine1.hashCode()); Object $payeeAddressCity = this.getPayeeAddressCity(); result = result * 59 + ($payeeAddressCity == null ? 43 : $payeeAddressCity.hashCode()); Object $payeeAddressStateOrProv = this.getPayeeAddressStateOrProv(); result = result * 59 + ($payeeAddressStateOrProv == null ? 43 : $payeeAddressStateOrProv.hashCode()); Object $payeeAddressZipOrPostal = this.getPayeeAddressZipOrPostal(); result = result * 59 + ($payeeAddressZipOrPostal == null ? 43 : $payeeAddressZipOrPostal.hashCode()); Object $payeeAddressCountryCode = this.getPayeeAddressCountryCode(); result = result * 59 + ($payeeAddressCountryCode == null ? 43 : $payeeAddressCountryCode.hashCode()); Object $routingNumber = this.getRoutingNumber(); result = result * 59 + ($routingNumber == null ? 43 : $routingNumber.hashCode()); Object $accountNumber = this.getAccountNumber(); result = result * 59 + ($accountNumber == null ? 43 : $accountNumber.hashCode()); Object $accountName = this.getAccountName(); result = result * 59 + ($accountName == null ? 43 : $accountName.hashCode()); return result; } public String toString() { UUID var10000 = this.getPaymentId(); return "SendPayment(paymentId=" + var10000 + ", payeeId=" + this.getPayeeId() + ", payoutMemo=" + this.getPayoutMemo() + ", amount=" + this.getAmount() + ", payeeFirstName=" + this.getPayeeFirstName() + ", payeeLastName=" + this.getPayeeLastName() + ", payeeAddressLine1=" + this.getPayeeAddressLine1() + ", payeeAddressCity=" + this.getPayeeAddressCity() + ", payeeAddressStateOrProv=" + this.getPayeeAddressStateOrProv() + ", payeeAddressZipOrPostal=" + this.getPayeeAddressZipOrPostal() + ", payeeAddressCountryCode=" + this.getPayeeAddressCountryCode() + ", routingNumber=" + this.getRoutingNumber() + ", accountNumber=" + this.getAccountNumber() + ", accountName=" + this.getAccountName() + ")"; } public SendPayment() { } public SendPayment(final UUID paymentId, final UUID payeeId, final String payoutMemo, final Long amount, final String payeeFirstName, final String payeeLastName, final String payeeAddressLine1, final String payeeAddressCity, final String payeeAddressStateOrProv, final String payeeAddressZipOrPostal, final String payeeAddressCountryCode, final String routingNumber, final String accountNumber, final String accountName) { this.paymentId = paymentId; this.payeeId = payeeId; this.payoutMemo = payoutMemo; this.amount = amount; this.payeeFirstName = payeeFirstName; this.payeeLastName = payeeLastName; this.payeeAddressLine1 = payeeAddressLine1; this.payeeAddressCity = payeeAddressCity; this.payeeAddressStateOrProv = payeeAddressStateOrProv; this.payeeAddressZipOrPostal = payeeAddressZipOrPostal; this.payeeAddressCountryCode = payeeAddressCountryCode; this.routingNumber = routingNumber; this.accountNumber = accountNumber; this.accountName = accountName; } }
Payment Entity
Our project also needs a basic Hibernate Entity. This will be used to persist payments to the database.
Hibernate configuration is beyond the scope of this post.
Payment
Following is our implementation of the Payment Entity.
PaymentEntity
@Entity @Data @NoArgsConstructor @AllArgsConstructor public class Payment { @Id @GeneratedValue(generator = "uuid2") @GenericGenerator(name = "uuid2", strategy = "uuid2") private UUID id; @Version private Integer version; private UUID paymentId; private Long amount; private String routingNumber; private String accountNumber; /** * SVB ACH Id - set with response from SVB */ private String SvbId; private String svbBatchId; private String svbUrl; @CreationTimestamp @Column(updatable = false) private Timestamp createdDate; @UpdateTimestamp private Timestamp lastModifiedDate; }
SVB Model
The GitHub repository has the complete model I wrote for the ACH API.
For brevity, I’m omitting the code for several Enums. The complete project is in GitHub here.
AchTransferObject
On this example, I’m also using the Project Lombok @Builder
annotation. We’ll use this later to inspect the mappers generated with and with builders.
@Getter @Setter @Builder public class AchTransferObject { private String accountNumber; private Integer amount; private String batchId; private Integer counterpartyId; @Builder.Default private SvbCurrency currency = SvbCurrency.USD; @Builder.Default private AchDirection direction = AchDirection.CREDIT; @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") private LocalDate effectiveDate; private String id; private String memo; private Object metadata; @NotNull //todo - not required if counter party id is provided, assuming we are not using this private String receiverAccountNumber; @Builder.Default private AchAccountType receiverAccountType = AchAccountType.CHECKING; private String receiverName; private String receiverRoutingNumber; @JsonProperty("return") //return is Java key word private String returnValue; @Builder.Default //todo - review this value @NotNull private SecCode secCode = SecCode.PPD; private AchService service; private AchStatus status ; private String type; private String url; }
MapStruct Mappers
In this section, we will implement the MapStruct Mappers.
By default, MapStruct will automatically map properties where the property name and types match. It will also map automatically if it can safely do an implicit type conversation. (Like an Integer to Long)
Mapping Interface
Below is the mapping interface. The interface itself is annotated with @Mapper
which instructs MapStruct to generate mappers from it.
Two methods are defined. One to accept a SendPayment
object and return a Payment
object.
A second to accept a Payment
object and return a AchTransferObject
object.
I’m also excluding one property from the mapper. The annotation @Mapping(target = "id", ignore = true)
excludes mapping to theid
property.
Without this exclusion, compiling will fail due to incompatible types. (UUID to String)
PaymentMapper
@Mapper public interface PaymentMapper { Payment sendPaymentToPayment(SendPayment sendPayment); @Mapping(target = "id", ignore = true) AchTransferObject paymentToAchTransferObject(Payment payment); }
NOTE: MapStruct has some very robust mapping capabilities. I will NOT be exploring those in this post. This is easily a post worthy topic! You can learn more here.
PaymentMapperImpl
Below is the mapping implementation generated by MapStruct. After running the Maven compile goal, you will find this class under /target/generated-sources/annotations/<package>
.
@Generated( value = "org.mapstruct.ap.MappingProcessor", date = "2021-02-05T09:46:19-0500", comments = "version: 1.4.1.Final, compiler: javac, environment: Java 11 (Oracle Corporation)" ) @Component public class PaymentMapperImpl implements PaymentMapper { @Override public Payment sendPaymentToPayment(SendPayment sendPayment) { if ( sendPayment == null ) { return null; } Payment payment = new Payment(); payment.setPaymentId( sendPayment.getPaymentId() ); payment.setAmount( sendPayment.getAmount() ); payment.setRoutingNumber( sendPayment.getRoutingNumber() ); payment.setAccountNumber( sendPayment.getAccountNumber() ); return payment; } @Override public AchTransferObject paymentToAchTransferObject(Payment payment) { if ( payment == null ) { return null; } AchTransferObjectBuilder achTransferObject = AchTransferObject.builder(); achTransferObject.accountNumber( payment.getAccountNumber() ); if ( payment.getAmount() != null ) { achTransferObject.amount( payment.getAmount().intValue() ); } return achTransferObject.build(); } }
Several things I’d like to point out in the generated code.
You can see this is annotated with a Spring Stereotype, marking it as a Spring Component. This is very handy in Spring projects. It allows you to easily autowire the mapper into other Spring managed components.
On two of the POJOs, I did not use the Project Lombok @Builder
annotation. Normally, I would have.
But, I wanted to demonstrate the differences in the generated code. You can see in the first method uses setters.
While the second method uses the builder created by Project Lombok.
You’ll also notice that a number of properties did not get mapped.
Easy enough to fix with additional MapStruct configuration.
Conclusion
In this post you can clearly see how much coding MapStruct and Project Lombok can save you.
Personally, I’m a fan of the builder pattern. It’s nice to use. BUT – before Project Lombok, it was tedious to implement!
I’ve written a lot of code using Lombok builders. They are very convenient to use.
One risk is violating the DRY Principle. A.K.A – Don’t Repeat Yourself.
In a larger project, you run the risk of doing the same type conversion using builders in multiple locations.
With each implementation, you run the risk of being inconsistent and possibly introducing a bug by forgetting a property.
Once you become accustomed to using MapStruct mappers, the type conversion is defined in one place.
If a new property is added or removed, you have one thing to change. Not every instance where the builder is used.
Here you can see the combination leads you to cleaner code, higher quality code, and saves you time.
It’s a win win win!
Anna
Thank you very much! This article solved my bug in maven project.
hp
Seems like it’s no longer working with spring 2.5.*.
hp
May fault. lombok 1.18.22 fixes it.