Series MapLesson 23 / 32
Deepen PracticeOrdered learning track

Learn Java Data Mapper Json Xml Validation Part 023 Mapstruct Basic To Advanced Mapping

10 min read1979 words
PrevNext
Lesson 2332 lesson track1927 Deepen Practice

title: Learn Java Data Mapper, JSON/XML Processing & Validation - Part 023 description: MapStruct mapping deep dive untuk field, nested object, collections, maps, enums, constants, default values, expressions, qualifiers, multiple source parameters, and production-safe conversion. series: learn-java-data-mapper-json-xml-validation seriesTitle: Learn Java Data Mapper, JSON/XML Processing & Validation order: 23 partTitle: MapStruct Mapping: Fields, Nested Objects, Collections, Enums, Expressions, Constants tags:

  • java
  • mapstruct
  • data-mapper
  • dto
  • nested-mapping
  • collection-mapping
  • enum-mapping
  • expressions
  • constants
  • qualifiers date: 2026-06-29

Part 023 — MapStruct Mapping: Fields, Nested Objects, Collections, Enums, Expressions, Constants

Target skill: mampu menggunakan MapStruct untuk mapping field, nested object, collection, map, enum, constants, default values, expressions, qualifiers, dan multiple source parameters tanpa kehilangan semantic correctness.

Part 022 membahas arsitektur MapStruct: annotation processing, generated code, compile-time safety. Sekarang kita masuk ke feature-level mapping yang paling sering dipakai di proyek nyata.

Mental model utama:

MapStruct maps structure. You define meaning.

Jika nama field sama dan meaning sama, convention mapping cukup. Jika nama sama tapi meaning berbeda, buat mapping eksplisit. Jika nama berbeda tapi meaning sama, @Mapping menjembatani. Jika transformasi membawa policy, pindahkan ke value object/domain service atau mapper support method yang jelas.


1. Kaufman Deconstruction

Subskill Part 023:

SubskillKemampuan
Basic property mappingMengandalkan same-name mapping secara aman
Explicit field mappingMenggunakan @Mapping(target, source)
Nested source mappingMapping source = "customer.id.value"
Nested target mappingMapping ke object target nested
Flattening/compositionMenggabungkan nested source ke flat DTO atau sebaliknya
Collection mappingMapping List, Set, array, iterable
Map mappingMapping Map<K,V> dengan key/value conversion
Enum mappingMenggunakan @ValueMapping, MappingConstants, explicit code mapping
Constants/defaultsMemakai constant, defaultValue, defaultExpression dengan hati-hati
ExpressionsMemakai expression = "java(...)" hanya untuk transformation kecil
QualifiersMemilih conversion method dengan @Named / qualifier
Multiple source paramsMapping dari beberapa input objects
Test mapping intentUnit/golden/enum coverage/null policy tests

2. Basic Same-Name Mapping

Source:

public record Customer(
    String customerId,
    String fullName,
    String email
) {}

Target:

public record CustomerResponse(
    String customerId,
    String fullName,
    String email
) {}

Mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerMapper {
    CustomerResponse toResponse(Customer customer);
}

MapStruct maps same-name compatible properties automatically.

Generated logic conceptually:

return new CustomerResponse(
    customer.customerId(),
    customer.fullName(),
    customer.email()
);

Use convention when:

  • names match
  • types match or conversion is obvious and safe
  • meaning matches
  • target is not security-sensitive
  • tests cover output shape

Do not rely on convention if semantic differs.


3. Explicit Property Mapping

Source:

public record Customer(
    CustomerId id,
    String legalName
) {}

Target:

public record CustomerResponse(
    String customerId,
    String fullName
) {}

Mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerMapper {

    @Mapping(target = "customerId", source = "id.value")
    @Mapping(target = "fullName", source = "legalName")
    CustomerResponse toResponse(Customer customer);
}

This documents meaning.

Review question for every explicit mapping:

Is this name translation, type conversion, semantic conversion, or business decision?
  • name translation: good
  • type conversion: good if deterministic
  • semantic conversion: okay if local and tested
  • business decision: usually not mapper responsibility

4. Nested Source Mapping

Source:

public record Order(
    OrderId id,
    Customer customer,
    Money total
) {}

public record Customer(
    CustomerId id,
    String fullName
) {}

Target:

public record OrderResponse(
    String orderId,
    String customerId,
    String customerName,
    String totalAmount,
    String currency
) {}

Mapper:

@Mapper(
    unmappedTargetPolicy = ReportingPolicy.ERROR,
    uses = MoneyMapper.class
)
public interface OrderMapper {

    @Mapping(target = "orderId", source = "id.value")
    @Mapping(target = "customerId", source = "customer.id.value")
    @Mapping(target = "customerName", source = "customer.fullName")
    @Mapping(target = "totalAmount", source = "total.amount")
    @Mapping(target = "currency", source = "total.currency.currencyCode")
    OrderResponse toResponse(Order order);
}

MapStruct generates null checks for nested paths depending configuration and source shape.

Be careful:

  • nested source can hide object graph traversal
  • lazy loaded entity relations can be triggered
  • deep nested mapping can leak internal shape
  • flattening should be intentional projection

5. Nested Target Mapping

Target:

public class OrderDto {
    private String orderId;
    private CustomerDto customer;
    private MoneyDto total;

    // getters/setters
}

Mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface OrderMapper {

    @Mapping(target = "orderId", source = "id.value")
    @Mapping(target = "customer.customerId", source = "customer.id.value")
    @Mapping(target = "customer.fullName", source = "customer.fullName")
    @Mapping(target = "total.amount", source = "total.amount")
    @Mapping(target = "total.currency", source = "total.currency.currencyCode")
    OrderDto toDto(Order order);
}

This creates nested target objects if MapStruct can instantiate them.

Review generated code to ensure:

  • target nested objects are created
  • null source handling matches expectation
  • no unexpected target overwrite
  • constructor/setter/builder path is correct

6. Flattening and Composition

Flattening:

public record CustomerProfile(
    Customer customer,
    Address address,
    RiskProfile risk
) {}

Target:

public record CustomerProfileResponse(
    String customerId,
    String fullName,
    String city,
    String riskSegment
) {}

Mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerProfileMapper {

    @Mapping(target = "customerId", source = "customer.id.value")
    @Mapping(target = "fullName", source = "customer.fullName")
    @Mapping(target = "city", source = "address.city")
    @Mapping(target = "riskSegment", source = "risk.segment")
    CustomerProfileResponse toResponse(CustomerProfile profile);
}

Composition from multiple source parameters:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerProfileMapper {

    @Mapping(target = "customerId", source = "customer.id.value")
    @Mapping(target = "fullName", source = "customer.fullName")
    @Mapping(target = "city", source = "address.city")
    @Mapping(target = "riskSegment", source = "risk.segment")
    CustomerProfileResponse toResponse(
        Customer customer,
        Address address,
        RiskProfile risk
    );
}

Use multiple source parameters when the projection is assembled from multiple read models.


7. Mapping target = "."

MapStruct supports mapping nested bean properties to current target using target = ".".

Example:

public record CustomerRecord(
    Customer customer,
    Address address
) {}

public record CustomerFlatDto(
    String name,
    String street,
    String city
) {}

Mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerFlatMapper {

    @Mapping(target = ".", source = "customer")
    @Mapping(target = ".", source = "address")
    CustomerFlatDto toDto(CustomerRecord record);
}

This can reduce repetitive mapping for flattening, but it can also hide conflicts.

Use when:

  • source beans have clearly disjoint property names
  • conflict resolution is explicit
  • generated code is inspected
  • tests cover field mapping

Avoid if it makes mapping less readable.


8. Constants

Set fixed target value:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface EventMapper {

    @Mapping(target = "schemaVersion", constant = "1")
    @Mapping(target = "eventType", constant = "customer.created")
    CustomerCreatedEvent toEvent(Customer customer);
}

Good use:

  • event type
  • schema version
  • source system code
  • fixed discriminator
  • technical contract field

Bad use:

@Mapping(target = "status", constant = "APPROVED")

if approval is a domain decision.


9. Default Values

defaultValue applies when source is null.

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerMapper {

    @Mapping(target = "displayName", source = "fullName", defaultValue = "Unknown")
    CustomerResponse toResponse(Customer customer);
}

Use with caution.

Question:

Is default part of presentation, contract compatibility, or business truth?

Default value is acceptable for:

  • display fallback
  • legacy contract requiring field
  • optional label
  • system code when source absent by design

Avoid for:

  • money amount
  • identity
  • workflow status
  • timestamp
  • regulatory facts
  • permissions

Silent defaults can corrupt meaning.


10. Default Expression

@Mapper(imports = UUID.class)
public interface RequestMapper {

    @Mapping(
        target = "requestId",
        source = "requestId",
        defaultExpression = "java(UUID.randomUUID().toString())"
    )
    InternalRequest toInternal(ApiRequest request);
}

Use sparingly.

For request IDs, timestamps, current user, tenant, and audit fields, prefer use-case layer or explicit context object so behavior is testable and deterministic.

Better:

CreatePaymentCommand toCommand(
    CreatePaymentRequest request,
    @Context RequestContext context
);

or pass generated value as source:

@Mapping(target = "requestId", source = "requestId")
Command toCommand(Request request, RequestId requestId);

11. Expressions

Expression example:

@Mapper(imports = {Currency.class})
public interface MoneyMapper {

    @Mapping(
        target = "currency",
        expression = "java(Currency.getInstance(dto.currency()))"
    )
    Money toMoney(MoneyDto dto);
}

Expressions are powerful but less refactor-safe than normal methods.

Prefer named default methods:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface MoneyMapper {

    @Mapping(target = "currency", source = "currency")
    Money toMoney(MoneyDto dto);

    default Currency toCurrency(String code) {
        return code == null ? null : Currency.getInstance(code);
    }
}

This is easier to test and reuse.

Use expressions for:

  • tiny glue code
  • constructor call not expressible otherwise
  • constant object creation
  • simple derived field

Avoid expressions for:

  • complex branching
  • service calls
  • database lookup
  • permission logic
  • workflow transition
  • feature flags

12. Qualifiers with @Named

Problem: two ways to map same type.

public class DateTimeMapper {

    @Named("isoInstant")
    public Instant isoInstant(String value) {
        return value == null ? null : Instant.parse(value);
    }

    @Named("epochMillis")
    public Instant epochMillis(Long value) {
        return value == null ? null : Instant.ofEpochMilli(value);
    }
}

Mapper:

@Mapper(
    unmappedTargetPolicy = ReportingPolicy.ERROR,
    uses = DateTimeMapper.class
)
public interface EventMapper {

    @Mapping(target = "occurredAt", source = "occurredAt", qualifiedByName = "isoInstant")
    EventCommand toCommand(EventRequest request);
}

Use qualifiers when:

  • multiple conversion methods exist
  • same source/target types have different semantic conversions
  • boundary-specific conversion is needed
  • you want explicit mapping policy

Without qualifier, MapStruct may report ambiguous mapping or choose unintended method depending context.


13. Custom Qualifier Annotation

For stronger typing than string-based @Named:

@Qualifier
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface IsoInstant {
}

Mapper method:

public class DateTimeMapper {

    @IsoInstant
    public Instant parseIsoInstant(String value) {
        return value == null ? null : Instant.parse(value);
    }
}

Use:

@Mapping(target = "occurredAt", source = "occurredAt", qualifiedBy = IsoInstant.class)
EventCommand toCommand(EventRequest request);

This avoids string typo in qualifiedByName.


14. Collection Mapping

Source/target:

public record Order(
    List<OrderLine> lines
) {}

public record OrderResponse(
    List<OrderLineResponse> lines
) {}

Mapper:

@Mapper(
    unmappedTargetPolicy = ReportingPolicy.ERROR,
    uses = OrderLineMapper.class
)
public interface OrderMapper {
    OrderResponse toResponse(Order order);
}

Line mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface OrderLineMapper {
    OrderLineResponse toResponse(OrderLine line);
}

MapStruct generates loop-based mapping.

Conceptually:

List<OrderLineResponse> list = new ArrayList<>(lines.size());
for (OrderLine line : lines) {
    list.add(orderLineMapper.toResponse(line));
}

Review:

  • null source collection behavior
  • mutable vs immutable target list
  • collection implementation
  • ordering preservation
  • nested mapper used
  • performance for large collections

15. Set Mapping

Set<TagDto> toDtos(Set<Tag> tags);

Set mapping preserves set semantics, but order may not be stable unless source/target set implementation preserves order.

For stable output, consider List or LinkedHashSet.

Do not expose unordered set in JSON response if consumer expects deterministic order.


16. Map Mapping

Map<String, AttributeDto> toDtoMap(Map<String, Attribute> attributes);

Key/value conversions can be defined:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface AttributeMapper {

    Map<String, AttributeDto> toDtoMap(Map<String, Attribute> attributes);

    AttributeDto toDto(Attribute attribute);
}

For key conversion:

default String keyToString(AttributeKey key) {
    return key == null ? null : key.value();
}

Maps are useful for dynamic attributes, but should have governance:

  • max entries
  • allowed keys
  • value type policy
  • key normalization
  • order if output matters
  • unknown/extension handling

17. Enum Mapping

Same-name enum:

public enum DomainStatus {
    PENDING,
    APPROVED,
    REJECTED
}

public enum ApiStatus {
    PENDING,
    APPROVED,
    REJECTED
}

Mapper:

ApiStatus toApiStatus(DomainStatus status);

Different names:

public enum ProviderStatus {
    WAITING,
    PAID,
    FAIL
}

public enum PaymentStatus {
    PENDING,
    COMPLETED,
    FAILED
}

Mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface PaymentStatusMapper {

    @ValueMapping(source = "WAITING", target = "PENDING")
    @ValueMapping(source = "PAID", target = "COMPLETED")
    @ValueMapping(source = "FAIL", target = "FAILED")
    PaymentStatus toDomain(ProviderStatus status);
}

This is exactly where compile-time mapping helps.


18. MappingConstants

For unknown/remaining enum values:

@Mapper
public interface ProviderStatusMapper {

    @ValueMapping(source = "WAITING", target = "PENDING")
    @ValueMapping(source = "PAID", target = "COMPLETED")
    @ValueMapping(source = MappingConstants.ANY_REMAINING, target = "UNKNOWN")
    PaymentStatus toDomain(ProviderStatus status);
}

or:

@ValueMapping(source = MappingConstants.NULL, target = "UNKNOWN")

Use carefully:

StrategyMeaning
explicit all valuessafest for closed enum
ANY_REMAININGmap unmapped same-name capable values or leftovers
ANY_UNMAPPEDcatch any unmapped source
NULLdefine null source behavior
target MappingConstants.NULLmap to null intentionally

For external provider enum, preserving raw code may be better than collapsing to UNKNOWN.


19. Enum to String

default String statusToCode(PaymentStatus status) {
    if (status == null) {
        return null;
    }
    return switch (status) {
        case PENDING -> "P";
        case COMPLETED -> "C";
        case FAILED -> "F";
    };
}

String to enum:

default PaymentStatus codeToStatus(String code) {
    if (code == null) {
        return null;
    }
    return switch (code.trim().toUpperCase(Locale.ROOT)) {
        case "P" -> PaymentStatus.PENDING;
        case "C" -> PaymentStatus.COMPLETED;
        case "F" -> PaymentStatus.FAILED;
        default -> throw new IllegalArgumentException("unknown payment status code: " + code);
    };
}

For provider data, consider:

public record ProviderStatusValue(
    String raw,
    KnownStatus known
) {}

instead of losing raw code.


20. Multiple Source Parameters

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CaseResponseMapper {

    @Mapping(target = "caseId", source = "caseRecord.id")
    @Mapping(target = "title", source = "caseRecord.title")
    @Mapping(target = "assigneeName", source = "assignee.displayName")
    @Mapping(target = "queueName", source = "queue.name")
    CaseDetailResponse toResponse(
        CaseRecord caseRecord,
        UserSummary assignee,
        QueueSummary queue
    );
}

Use when response projection is assembled from multiple sources.

If two sources have same property name and target is ambiguous, specify source explicitly.


21. @BeanMapping

@BeanMapping can configure a mapping method.

Example with explicit ignore by default:

@BeanMapping(ignoreByDefault = true)
@Mapping(target = "caseId", source = "id.value")
@Mapping(target = "title", source = "title")
CaseSummaryResponse toSummary(Case domain);

This is useful for summary projections where you intentionally map only a few fields.

Without ignoreByDefault, unmappedTargetPolicy = ERROR would force all target fields mapped.

Use for:

  • summary response
  • search result projection
  • export row subset
  • privacy-safe projection
  • partial view

22. Mapping Inheritance

If several methods share mapping config:

@Mapping(target = "customerId", source = "id.value")
@Mapping(target = "fullName", source = "legalName")
CustomerResponse toResponse(Customer customer);

@InheritConfiguration(name = "toResponse")
CustomerSummary toSummary(Customer customer);

Use carefully. Inheritance can reduce repetition but also hide mapping intent.

Prefer it when mapping variants are genuinely aligned.

Avoid if summary/detail fields have different semantic rules.


23. Mapping Nested Object via Helper Method

Instead of many nested target fields:

@Mapper(
    unmappedTargetPolicy = ReportingPolicy.ERROR,
    uses = {CustomerMapper.class, MoneyMapper.class}
)
public interface OrderMapper {
    OrderResponse toResponse(Order order);
}

Customer mapper:

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface CustomerMapper {
    @Mapping(target = "customerId", source = "id.value")
    CustomerSummary toSummary(Customer customer);
}

Target:

public record OrderResponse(
    String orderId,
    CustomerSummary customer,
    MoneyResponse total
) {}

This is often cleaner than flattening everything.


24. Object Factories

If target construction needs factory logic:

public class MoneyFactory {

    @ObjectFactory
    public Money create(MoneyDto dto) {
        if (dto == null) {
            return null;
        }
        return new Money(
            new BigDecimal(dto.amount()),
            Currency.getInstance(dto.currency())
        );
    }
}

Use factories when target object creation is non-trivial but still representation-level.

Do not use factory to fetch data from database or make workflow decisions.

Object factories are explored more in Part 025.


25. Implicit Conversion Caution

MapStruct supports many built-in conversions. This is convenient, but not always semantically correct.

Examples to review:

ConversionRisk
String ↔ numericleading zero loss, invalid format
numeric widening/narrowingoverflow
enum ↔ stringexternal code vs enum name
date/time ↔ stringformat/timezone ambiguity
collection conversionmutability/order
primitive defaultnull becomes zero/false depending path
BigDecimal ↔ Stringscale/format

For boundary-critical values, prefer explicit conversion method.


26. Mapping Tests

26.1 Field Mapping

@Test
void mapsCustomerFields() {
    Customer customer = new Customer(new CustomerId("CUS-001"), "Ana");

    CustomerResponse response = mapper.toResponse(customer);

    assertThat(response.customerId()).isEqualTo("CUS-001");
    assertThat(response.fullName()).isEqualTo("Ana");
}

26.2 Nested Mapping

@Test
void mapsNestedCustomerSummary() {
    Order order = fixtureOrder();

    OrderResponse response = mapper.toResponse(order);

    assertThat(response.customer().customerId()).isEqualTo("CUS-001");
}

26.3 Collection Mapping

@Test
void mapsOrderLinesInOrder() {
    Order order = fixtureOrderWithLines("A", "B");

    OrderResponse response = mapper.toResponse(order);

    assertThat(response.lines())
        .extracting(OrderLineResponse::sku)
        .containsExactly("A", "B");
}

26.4 Enum Mapping Coverage

@Test
void allProviderStatusesMap() {
    for (ProviderStatus status : ProviderStatus.values()) {
        assertThatCode(() -> mapper.toDomain(status)).doesNotThrowAnyException();
    }
}

26.5 Generated Code Inspection

For critical mapper, add code review step:

Open generated mapper.
Verify nested null checks.
Verify enum switch.
Verify collection implementation.
Verify no unexpected default.

27. Production Checklist

Before approving MapStruct mappings:

  • Are same-name mappings semantically correct?
  • Are different-name mappings explicit?
  • Are nested paths intentional?
  • Are deep graph traversals avoided?
  • Are collection order/mutability expectations tested?
  • Are map key/value conversions explicit?
  • Are enum mappings explicit for external codes?
  • Are constants/defaults truly contract-level, not business decisions?
  • Are expressions small and deterministic?
  • Are qualifiers used for ambiguous conversion?
  • Is @BeanMapping(ignoreByDefault = true) used for partial projections?
  • Are all intentional ignores documented?
  • Are mapper tests covering semantic fields?
  • Is generated code inspected for critical mapping?

28. Anti-Patterns

28.1 Same Name Means Same Meaning

Two fields named status may not have same lifecycle semantics.

28.2 Deep Path Mapping into Lazy Entity Graph

@Mapping(target = "teamName", source = "case.assignee.team.name")

Could trigger unwanted loading and leak graph design.

28.3 Constants for Domain State

Mapper should not approve, reject, close, escalate, or assign.

28.4 Default Value for Missing Critical Data

Defaulting missing amount, currency, id, or timestamp hides data quality problems.

28.5 Expression Dumping Ground

Huge Java expression strings are hard to test and refactor.

28.6 ANY_REMAINING Without Thinking

Unknown enum handling is compatibility policy, not convenience.


29. Mini Case Study: Provider Payment Mapping

Provider DTO:

public record ProviderPaymentDto(
    String payment_id,
    String amount,
    String currency,
    String status,
    String created_at,
    List<ProviderPaymentLineDto> lines
) {}

Command:

public record PaymentImportedCommand(
    PaymentId paymentId,
    Money money,
    ProviderPaymentStatus status,
    Instant createdAt,
    List<PaymentLineCommand> lines
) {}

Mapper:

@Mapper(
    config = CentralMapperConfig.class,
    uses = {
        PaymentIdMapper.class,
        MoneyMapper.class,
        DateTimeMapper.class,
        PaymentLineMapper.class
    }
)
public interface ProviderPaymentMapper {

    @Mapping(target = "paymentId", source = "payment_id")
    @Mapping(target = "money", source = ".")
    @Mapping(target = "status", source = "status", qualifiedByName = "providerStatus")
    @Mapping(target = "createdAt", source = "created_at", qualifiedByName = "isoInstant")
    PaymentImportedCommand toCommand(ProviderPaymentDto dto);

    @Named("providerStatus")
    default ProviderPaymentStatus toProviderStatus(String raw) {
        if (raw == null) {
            return null;
        }
        return switch (raw.trim().toUpperCase(Locale.ROOT)) {
            case "WAITING" -> ProviderPaymentStatus.PENDING;
            case "PAID" -> ProviderPaymentStatus.COMPLETED;
            case "FAIL" -> ProviderPaymentStatus.FAILED;
            default -> ProviderPaymentStatus.UNKNOWN;
        };
    }
}

Money mapper:

public class MoneyMapper {
    public Money toMoney(ProviderPaymentDto dto) {
        if (dto == null) {
            return null;
        }
        return new Money(
            new BigDecimal(dto.amount()),
            Currency.getInstance(dto.currency())
        );
    }
}

Review:

  • provider field names are explicit
  • money conversion centralized
  • date conversion qualified
  • status strategy explicit
  • lines delegated
  • no business approval logic in mapper

30. Practice Drill

Given:

public record CaseApiRequest(
    String caseId,
    String title,
    String priority,
    String reporterUserId,
    List<String> tags,
    Map<String, String> attributes
) {}

Target:

public record CreateCaseCommand(
    CaseId caseId,
    String title,
    Priority priority,
    UserId reporter,
    List<Tag> tags,
    Map<AttributeKey, AttributeValue> attributes
) {}

Tasks:

  1. Create mapper with strict target policy.
  2. Map caseId using value object mapper.
  3. Map priority explicitly.
  4. Map reporterUserId to UserId.
  5. Map tags to Tag.
  6. Map attribute key/value types.
  7. Add tests for list order.
  8. Add tests for unknown priority.
  9. Add tests for blank attribute key.
  10. Explain which validations should happen before mapper.

31. Summary

MapStruct’s mapping features are powerful, but their correctness depends on semantic discipline.

Mental model:

Let MapStruct remove mechanical boilerplate, not engineering judgement.

Rules:

  1. Use convention for safe same-name mappings.
  2. Use explicit @Mapping for semantic clarity.
  3. Keep nested mapping intentional and shallow enough.
  4. Delegate nested object mapping to helper mappers.
  5. Test collection order and null behavior.
  6. Use explicit enum mapping for external statuses.
  7. Treat constants/defaults as contract decisions.
  8. Prefer named methods/qualifiers over complex expressions.
  9. Use @BeanMapping(ignoreByDefault = true) for summary/projection mappings.
  10. Inspect generated code for critical paths.

Next part deep dives into update, patch, and partial mapping: @MappingTarget, null strategies, merge semantics, dirty intent, and why absence/null/default must be treated as different states.


References

Lesson Recap

You just completed lesson 23 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.