home / skills / emvnuel / skill.md / mapstruct-patterns

mapstruct-patterns skill

/mapstruct-patterns

This skill helps enforce compile-time safety in MapStruct by promoting constructor-based mapping and a custom Default annotation trick.

npx playbooks add skill emvnuel/skill.md --skill mapstruct-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (6)
SKILL.md
4.3 KB
---
name: mapstruct-patterns
description: Constructor-based MapStruct mapping for compile-time safety in Jakarta EE. Use when implementing DTO mapping or using @Default annotations.
---

# MapStruct Patterns for Jakarta EE

Best practices for using MapStruct with constructor-based mapping to achieve compile-time safety. When constructors change, mappings fail to compile — no runtime surprises.

## Core Philosophy

> **Use constructors, not setters**. This gives you compile-time errors when fields change.

Records naturally enforce this. For mutable entities, use the `@Default` annotation.

## CDI Setup

```java
@Mapper(componentModel = "cdi")  // CDI injection
public interface OrderMapper {
    OrderResponse toResponse(Order order);
}
```

## The @Default Annotation Trick

MapStruct uses any annotation named `@Default` to select the constructor. Create your own:

```java
package com.example.mapstruct;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.CONSTRUCTOR)
@Retention(RetentionPolicy.CLASS)
public @interface Default {
}
```

### Usage on Mutable Entities

```java
@Entity
public class Order {

    @Id @GeneratedValue
    private Long id;
    private String customerId;
    private BigDecimal total;
    private OrderStatus status;

    // JPA needs this
    protected Order() {}

    // MapStruct uses this - CHANGE HERE = COMPILER ERROR in mapper
    @Default
    public Order(String customerId, BigDecimal total, OrderStatus status) {
        this.customerId = customerId;
        this.total = total;
        this.status = status;
    }
}
```

## Records (Ideal Case)

Records automatically work with constructor mapping:

```java
// No @Default needed - single constructor
public record OrderResponse(
    String orderId,
    String customerId,
    String total,
    String status
) {}

@Mapper(componentModel = "cdi")
public interface OrderMapper {

    @Mapping(target = "orderId", source = "id")
    @Mapping(target = "total", expression = "java(order.getTotal().toString())")
    OrderResponse toResponse(Order order);
}
```

## Key Patterns

### 1. Constructor-Based Mapping

```java
@Mapper(componentModel = "cdi")
public interface CustomerMapper {

    // MapStruct uses Customer constructor, fail if signature changes
    Customer toEntity(CreateCustomerRequest request);

    // MapStruct uses CustomerResponse constructor
    CustomerResponse toResponse(Customer customer);
}
```

### 2. Custom @Default for Entities

```java
@Entity
public class Product {

    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;
    private String category;

    protected Product() {}

    @Default  // Your custom annotation
    public Product(String name, BigDecimal price, String category) {
        this.name = name;
        this.price = price;
        this.category = category;
    }
}
```

## Anti-Pattern: Setter-Based Mapping

```java
// ❌ Can add field to DTO, forget mapper, get null at runtime
public class OrderDTO {
    private String id;
    private String status;
    private String newField;  // Added later, no error!

    // Just setters...
}

// ✓ Add field to constructor = compiler error in mapper
public record OrderDTO(String id, String status, String newField) {}
```

## Compile-Time Safety Benefit

```java
// Before: Record has 3 fields
public record OrderResponse(String id, String status, String total) {}

// After: Added customerName field
public record OrderResponse(String id, String status, String total, String customerName) {}

// Mapper now FAILS TO COMPILE until you add the mapping:
@Mapper(componentModel = "cdi")
public interface OrderMapper {
    @Mapping(target = "customerName", source = "customer.name")  // Must add this
    OrderResponse toResponse(Order order);
}
```

## Cookbook Index

### Setup & Configuration

- [cdi-setup](cookbook/cdi-setup.md) - CDI/MicroProfile setup
- [default-annotation](cookbook/default-annotation.md) - Custom @Default annotation

### Mapping Patterns

- [constructor-mapping](cookbook/constructor-mapping.md) - Constructor-based mapping
- [record-mapping](cookbook/record-mapping.md) - Java Records mapping
- [entity-mapping](cookbook/entity-mapping.md) - JPA entity mapping

Overview

This skill documents constructor-based MapStruct mapping patterns for Jakarta EE to achieve compile-time safety when converting between entities, DTOs, and records. It emphasizes using constructors (and Java records) instead of setters so mapping breaks at compile time when shapes change. It also explains a simple custom @Default annotation to guide MapStruct for mutable JPA entities.

How this skill works

MapStruct generates mapping code that calls target constructors when a matching constructor signature is available. Using records or marking a single constructor with a custom @Default annotation forces MapStruct to use that constructor. If the constructor signature changes or a target field is added, the mapper fails to compile, surfacing the discrepancy during development rather than at runtime.

When to use it

  • Building DTOs or API responses in Jakarta EE where compile-time safety is important
  • Mapping JPA entities to immutable DTOs or records
  • Enforcing mapping correctness when domain or DTO shapes frequently evolve
  • Replacing setter-based mappers to prevent silent runtime nulls or missing fields
  • When using CDI componentModel for injection of mappers in Jakarta EE

Best practices

  • Prefer Java records for DTOs and responses when possible—records have a single constructor and work out of the box
  • For mutable JPA entities, add a protected no-arg constructor for JPA and a single @Default constructor for MapStruct
  • Create a simple @Default constructor annotation (CLASS retention) and annotate the constructor you want MapStruct to use
  • Declare mappers with @Mapper(componentModel = "cdi") to integrate with CDI in Jakarta EE
  • Avoid setter-based DTOs; use constructors so missing mappings break the build

Example use cases

  • Mapping Order JPA entity to an immutable OrderResponse record for a REST API
  • Converting CreateCustomerRequest DTO to a Customer entity using a constructor-based mapping
  • Enforcing compile-time checks after adding a new field to a response record so mappers must be updated
  • Using custom @Default on Product entity to select the constructor MapStruct should call
  • Replacing legacy setter mappers to prevent runtime surprises in production

FAQ

Why use a custom @Default annotation?

MapStruct selects a constructor when there are multiple; a custom @Default annotation marks the constructor you want MapStruct to use for mutable entities.

Do I still need a no-arg constructor for JPA entities?

Yes. Keep a protected no-arg constructor for JPA and provide a single annotated constructor for MapStruct to ensure both JPA and compile-time mapping work.