Mapping Bidirectional Object Associations using MapStruct

Two strategies to map bi-directional relationships between entities

Jonathan Manera
4 min readJan 7, 2023
Photo by Florida-Guidebook.com on Unsplash

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:

ERD for Orders

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!

--

--

Jonathan Manera

If you wish to make a Java app from scratch, you must first invent the universe.