Skip to content
Spring Boot sb dto 3 min read

MapStruct

MapStruct is a compile-time code generator for bean mappings. You declare a mapper interface and MapStruct writes the implementation during compilation — plain field-copying Java, no reflection. The result is as fast as hand-written code, fully type-safe, and any mismatch becomes a compile error rather than a runtime surprise.

Adding the dependency and processor

MapStruct needs two pieces: the runtime API on the classpath and the annotation processor that runs during compilation.

<properties>
    <org.mapstruct.version>1.6.3</org.mapstruct.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>
</dependencies>

The processor is configured on the compiler plugin so the generated classes appear in target/generated-sources:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>org.mapstruct</groupId>
                <artifactId>mapstruct-processor</artifactId>
                <version>${org.mapstruct.version}</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

Ordering with Lombok

If you use Lombok, the two processors compete: Lombok must generate getters and setters before MapStruct reads them. Add the lombok-mapstruct-binding so they run in the right order, and list Lombok first in the processor paths.

<annotationProcessorPaths>
    <path>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
    </path>
    <path>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok-mapstruct-binding</artifactId>
        <version>0.2.0</version>
    </path>
    <path>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct-processor</artifactId>
        <version>${org.mapstruct.version}</version>
    </path>
</annotationProcessorPaths>

Warning: Forgetting lombok-mapstruct-binding produces confusing “property has no write accessor” errors, because MapStruct runs before Lombok has generated the setters. The binding is what makes the two cooperate.

Defining a mapper

Declare an interface and annotate it @Mapper. Setting componentModel = "spring" makes the generated implementation a Spring @Component you can inject like any other bean.

@Mapper(componentModel = "spring")
public interface ProductMapper {

    ProductResponse toDto(Product product);

    Product toEntity(CreateProductRequest request);

    List<ProductResponse> toDtoList(List<Product> products);
}

MapStruct matches fields by name automatically, so id, name, and price need no configuration.

@Mapping for field renames and expressions

When names differ or a value is nested, use @Mapping. The source is read from the input, the target is written on the output.

@Mapper(componentModel = "spring")
public interface ProductMapper {

    @Mapping(source = "category.name", target = "categoryName")
    @Mapping(target = "createdAt", expression = "java(java.time.Instant.now())")
    ProductResponse toDto(Product product);

    @Mapping(target = "id", ignore = true)
    @Mapping(target = "category", ignore = true)
    Product toEntity(CreateProductRequest request);
}
  • source = "category.name" flattens the nested association into categoryName.
  • ignore = true on id/category tells MapStruct not to map server-managed fields from the request.

Nested and collection mapping

If a referenced mapper handles a sub-type, list it in uses and MapStruct delegates automatically. Collections are mapped element-by-element using the single-element method.

@Mapper(componentModel = "spring", uses = CategoryMapper.class)
public interface OrderMapper {

    @Mapping(source = "items", target = "lineItems")
    OrderResponse toDto(Order order);   // List<Item> -> List<LineItemResponse>
}

MapStruct sees List<Item> -> List<LineItemResponse>, finds (or generates) an Item -> LineItemResponse mapping, and loops for you.

What the generated code looks like

MapStruct emits a normal class — readable, debuggable, no reflection:

@Component
public class ProductMapperImpl implements ProductMapper {

    @Override
    public ProductResponse toDto(Product product) {
        if (product == null) {
            return null;
        }
        String categoryName = null;
        if (product.getCategory() != null) {
            categoryName = product.getCategory().getName();
        }
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getPrice(),
                categoryName);
    }
}

Notice the automatic null-guards on the nested association.

Injecting the generated mapper

Because componentModel = "spring" registers it as a bean, inject it with constructor injection like any service.

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository repository;
    private final ProductMapper mapper;

    @Transactional(readOnly = true)
    public ProductResponse findById(Long id) {
        return mapper.toDto(repository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id)));
    }
}

MapStruct vs ModelMapper at a glance

AspectMapStructModelMapper
When mapping is resolvedCompile timeRuntime (reflection)
PerformanceNative speedSlower (reflection)
Mismatch detectedCompile errorRuntime / silent skip
SetupDependency + processorSingle dependency + @Bean

Tip: Set unmappedTargetPolicy = ReportingPolicy.ERROR on @Mapper to fail the build whenever a target field is left unmapped — turning silent gaps into compile errors.

Last updated June 13, 2026
Was this helpful?