Mapping Bidirectional Object Associations using MapStruct
Two strategies to map bi-directional relationships between entities
In object-oriented programming, an association is a relationship between two entities that defines how two objects communicate. An association can be one-to-one, one-to-many, many-to-one or many-to-many.
Bi-directional associations happen when two classes define their relationships with each other symmetrically (symmetrical associations).
In this article, we will explore two mapping strategies, between domain objects and JPA Entities with their relationships, using MapStruct.
Before we start, please note that it is assumed that you already have a basic understanding of MapStruct and JPA.
Association Relationship
Let’s look at the following Entity-Relationship diagram:
In this example, we can see the domain model for orders placed in a restaurant.
Three tables contain the data about an order:
- A parent table with the order information (ORDER_DETAIL).
- Two child tables, one containing consumer information (ORDER_CONSUMER) and one containing ordered items (ORDER_ITEM).
Parent-Child Associations
Order
(the parent) has a one-to-one relationship with Consumer
(meaning an order is associated with a single consumer) and a one-to-many relationship with OrderItem
(meaning each order can have multiple items).
@Entity
@Table(name = "order_detail")
@Access(AccessType.FIELD)
public class Order {
@Id
@GeneratedValue
@Column(name = "order_id", columnDefinition = "BINARY(16)")
private UUID id;
@OneToOne(mappedBy = "order", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, orphanRemoval = true)
private ConsumerJpaEntity consumer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, orphanRemoval = true)
private Set<OrderItemJpaEntity> items;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private OrderState state;
@Column(columnDefinition = "text")
private String notes;
private Instant createdAt;
private Instant updatedAt;
// Getters and Setters removed for simplicity
}
Here, we model the association for the consumer
field with the @OneToOne
annotation and for items
with @OneToMany
.
Both associations have the property fetch
set to FetchType.LAZY
, loading data only when necessary.
The associations also have the cascade
property set to CascadeType.ALL
, allowing operations to be performed on the parent and replicated in its associated child objects.
Child-Parent Associations
As each order is associated with a single consumer, Consumer
and Order
have a one-to-one relationship.
@Entity
@Table(name = "order_consumer")
@Access(AccessType.FIELD)
public class Consumer {
@Id
@GeneratedValue
@Column(name = "consumer_id", columnDefinition = "BINARY(16)")
private UUID id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private OrderJpaEntity order;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String address;
@Column(nullable = false)
private String phone;
// Getters and Setters removed for simplicity
}
This association is modeled with the annotations @OneToOne
and @JoinColumn(name="order_id")
.
In the following code example, each OrderItem
is associated with a specific Order
, in a many-to-one relationship.
@Entity
@Table(name = "order_item")
@Access(AccessType.FIELD)
public class OrderItem {
@Id
@GeneratedValue
@Column(name = "item_id", columnDefinition = "BINARY(16)")
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private OrderJpaEntity order;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private Integer quantity;
// Getters and Setters removed for simplicity
}
Here, we model the association using @ManyToOne
and @JoinColumn(name="order_id")
.
Mapping Strategies
First Strategy: Using @AfterMapping
The basic idea of this strategy is to map the attributes of a domain object or a DTO to the JPA Entity, and after that, to establish a bi-directional — symmetrical — relationship between the child entities and their parent.
@Mapper
public interface OrderServiceMapper {
@Mapping(target = "items", qualifiedByName = "orderItemDtoSetToOrderItemSet")
Order orderDtoToOrder(OrderDto order);
@IterableMapping(qualifiedByName = "orderItemDtoToOrderItem")
@Named("orderItemDtoSetToOrderItemSet")
Set<OrderItem> orderItemDtoSetToOrderItemSet(Set<OrderItemDto> list);
@Named("orderItemDtoToOrderItem")
OrderItem orderItemDtoToOrderItem(OrderItemDto item);
@AfterMapping
default void setOrder(@MappingTarget Order order) {
Optional.ofNullable(order.getConsumer())
.ifPresent(it -> it.setOrder(order));
Optional.ofNullable(order.getItems())
.ifPresent(it -> it.forEach(item -> item.setOrder(order)));
}
}
In the OrderServiceMapper
interface, setOrder()
is annotated with @AfterMapping
. This method will be invoked at the end of the mapping method orderDtoToOrder()
(right before the last return
statement).
The setOrder()
method has a parameter annotated with @MappingTarget
that holds the target of the mapping method, in this case, an instance of Order
. In this step, we establish the symmetrical relationship between Order
and its child objects: Consumer
and OrderItem
.
Second Strategy: Using Adders and Setters
In the second strategy, we map the attributes of a domain object or a DTO to the JPA Entity, similar to what was presented in the first strategy. The main difference is that we modify the parent entity to define a bi-directional — symmetrical — relationship with its child entities.
@Entity
@Table(name = "order_detail")
@Access(AccessType.FIELD)
public class Order {
// Fields removed for simplicity
public void setConsumer(Consumer consumer) {
this.consumer = consumer;
consumer.setOrder(this);
}
public void addItem(OrderItem item) {
if (this.items == null) {
this.items = new HashSet<>();
}
items.add(item);
item.setOrder(this);
}
}
Here, the setConsumer()
method sets the Consumer
in a one-to-one symmetrical relationship.
To add entities with one-to-many associations, we have created an adder. The addItem()
method initializes the collection if it’s null, adds items to the collection and sets the symmetrical relationship.
@Mapper(collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED)
public interface OrderServiceMapper {
@Mapping(target = "items", qualifiedByName = "orderItemDtoToOrderItem")
Order orderDtoToOrder(OrderDto order);
@Named("orderItemDtoToOrderItem")
OrderItem orderItemDtoToOrderItem(OrderItemDto item);
}
In the OrderServiceMapper
interface, we have to tell MapStruct to map collections using adders. In order to do this, the @Mapper
annotation has the collectionMappingStrategy
property set with the value CollectionMappingStrategy.ADDER_PREFERED
.
Summary
In this article, we explored how to map bi-directional object associations using MapStruct.
We looked at a strategy that uses the AfterMapping annotation. This is a good fit if you don’t want to add any logic in your target class. The complete code of the first strategy is available on GitHub.
If you already have logic in your target class or even if you don’t want to add MapStruct to your project, the second strategy might be best for you. The complete code of this strategy is available on GitHub.
Thanks for reading. I hope this was helpful!