Skip to main content

09 — MapStruct at ERROR: the boundary that refuses to guess

Read this first: this lesson reads the three files that define DTO-to-entity mapping in this repo — the shared config every mapper inherits, the shared name qualifiers it wires in, and one full module mapper — then closes with the only four places hand-written mapping is still legal. Code is quoted inline so you can read this without the repository open. By the end you can write a new mapper that compiles under the house config, and recite the four exceptions from memory.

Time: about 35 minutes. Assumes lesson 08.

One config, twenty mappers​

In lesson 08 the repository handed you entities. Entities never cross the controller boundary, so something has to turn them into DTOs — and ../coding-standards.md says exactly what that something is:

DTO ↔ entity mapping is MapStruct. Every mapper is an interface in mapper/ annotated @Mapper(config = MappingDefaults.class), named <Module>Mapper after the module's name prefix, and injected into the service impl like any other collaborator.

List the mapper/ package and you find 22 files: 20 module mappers (EmployeeMapper, PayrollMapper, LeaveMapper, CrmMapper, RecruitmentMapper, WarehouseMapper, EwtMapper, and so on — one per module name prefix) plus the two shared files this lesson opens with. The first is MappingDefaults, an interface that exists only to carry an annotation:

@MapperConfig(
componentModel = MappingConstants.ComponentModel.SPRING,
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
collectionMappingStrategy = CollectionMappingStrategy.TARGET_IMMUTABLE,
unmappedTargetPolicy = ReportingPolicy.ERROR,
unmappedSourcePolicy = ReportingPolicy.IGNORE,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_NULL,
typeConversionPolicy = ReportingPolicy.ERROR,
builder = @Builder(disableBuilder = true),
uses = PersonNames.class)
public interface MappingDefaults {
}

componentModel = SPRING plus injectionStrategy = CONSTRUCTOR means the impl MapStruct generates at compile time (EmployeeMapperImpl) is a constructor-injected Spring bean — which is why the coding standards can say "injected into the service impl like any other collaborator." A brand-new mapper is one line of opt-in and nothing else:

@Mapper(config = MappingDefaults.class)
public interface CertificateMapper {

Everything above the annotation is javadoc, and that javadoc is the real specification. Its opening paragraph:

The two ERROR policies are the point: a DTO field nobody filled or a lossy implicit conversion (Long -> Integer) is a compile error, not a runtime surprise. Skipping a field on purpose therefore requires an explicit @Mapping(target = "...", ignore = true) with a reason a reader can see.

The two ERROR policies are the point​

Unpack that paragraph. unmappedTargetPolicy = ERROR means every property on the target type must be accounted for — mapped from a source property, computed by an expression, or explicitly ignored. typeConversionPolicy = ERROR bans implicit narrowing: MapStruct will happily shove a Long into an Integer by default, and this repo will not let it.

The consequence you should internalize: an ignore = true in this codebase is never boilerplate. The compiler forced someone to write it, so the comment beside it is the design decision. You will see this pay off in the specimen below, where every ignore carries its reason inline.

Stated so nobody "fixes" it​

The rest of the javadoc exists for a different reason: the settings that look wrong to a well-meaning refactorer, documented so they survive. One per paragraph:

unmappedSourcePolicy stays IGNORE deliberately: summary DTOs map a handful of fields from wide entities (Employee has 25 properties, PositionSummaryDto takes 3), so unmapped sources are the normal case.

Target fields are promises to the API consumer; source fields are just what the entity happens to carry. Only the first kind of "unmapped" is a bug.

nullValuePropertyMappingStrategy stays SET (the MapStruct default, stated here so nobody "fixes" it): several update flows write null on purpose to clear a field — e.g. PayrollSettings clears a pinned contribution-table version by blanking it. Partial-update semantics belong on the individual method via @BeanMapping(nullValuePropertyMappingStrategy = IGNORE), exactly as EmployeeMapper#updateEntity does.

Read that twice, because it is a policy about where a decision lives: null-skipping is a per-method choice, never a package-wide one. A global IGNORE would make "clear this field" become silently impossible across every update flow at once.

TARGET_IMMUTABLE makes update methods replace collections through the setter instead of clear()+addAll() on the existing one — the hand-written mapping always replaced, and entity defaults are immutable sets that a clear() would throw on.

disableBuilder turns the documented Lombok-@Builder ban (docs/backend/annotations.md) into a compile-enforced fact.

That last one connects two documents: ../backend/annotations.md states the @Builder ban in prose, and disableBuilder means MapStruct never routes through a builder even if someone adds one — the convention holds by construction, not by review vigilance.

Predict: you add a middleName field to EmployeeDto and touch nothing else. When and how do you find out — first failing request, first failing test, or earlier? And what happens in the one scenario the config cannot reach: a mapper whose author forgot config = MappingDefaults.class?

The second lock in the pom​

Earlier than any test: mvn compile fails with an unmapped-target error naming the mapper method and the property, because unmappedTargetPolicy = ERROR runs inside the annotation processor. And the forgotten-config scenario is already handled, by the build file you read in lesson 01. The compiler args in ../../backend/pom.xml:

<compilerArgs>
<!-- Byte-stable generated impls: keeps the Docker application layer
reproducible and makes generated-code diffs reviewable. -->
<arg>-Amapstruct.suppressGeneratorTimestamp=true</arg>
<!-- Logs which mapping method MapStruct selects per property, so an
accidental lazy-association auto-map shows up in the build log. -->
<arg>-Amapstruct.verbose=true</arg>
<!-- Belt-and-braces behind MappingDefaults (@MapperConfig): a mapper
that forgets config = MappingDefaults.class still fails the build
on an unmapped target instead of silently warning. -->
<arg>-Amapstruct.unmappedTargetPolicy=ERROR</arg>
</compilerArgs>

Two locks on the same door: the @MapperConfig interface carries the policy for every mapper that opts in, and the processor flag catches the mapper that forgot to. Note verbose=true while you are here — MapStruct logs which method it selects per property, so a silent auto-map of a lazy association is at least visible in the build log.

PersonNames: the only uses​

The config's uses = PersonNames.class wires one helper into every mapper. It is a utility class of @Named qualifiers for the display-name derivation every person-shaped entity repeats:

@Named("employeeFullName")
public static String employeeFullName(Employee employee) {
return employee == null ? null : employee.getFirstName() + " " + employee.getLastName();
}

There are four of these — employee, applicant, CRM contact, CRM lead — and its javadoc carries a rule that follows directly from everything above:

Always write qualifiedByName explicitly, even where MapStruct could auto-select the method — an Entity-to-String mapping chosen silently is exactly the kind of surprise these policies exist to prevent.

So a call site always reads @Mapping(target = "...", source = "...", qualifiedByName = "employeeFullName"), never a bare mapping that happens to resolve. Two boundary notes from the same javadoc: "Callers still own the null-guard on the association itself," and "Entities that already store a full name (User, PortalUser) don't belong here."

The specimen: EmployeeMapper's five shapes​

EmployeeMapper — "Maps the org-structure module: Employee plus the Department and Position entities it hangs off, so the whole hierarchy lives in one mapper" — shows every method shape you will need. First, plain entity-to-DTO:

@Mapping(target = "position", source = "position")
@Mapping(target = "username", ignore = true) // set by the toDto(employee, username) overload
EmployeeDto toDto(Employee employee);

Second and third, the create pair. toEntity(EmployeeCreateRequest) builds a fresh entity, and right beside it sits a @MappingTarget twin with a comment that explains why they both exist:

// Create DTO -> existing Employee: the create flow's own field set, kept
// separate from toEntity on purpose so the two can drift independently.

@Mapping(target = "employeeNumber", ignore = true)
@Mapping(target = "position", ignore = true) // resolved from positionId via the repository by the service
@Mapping(target = "restDayOfWeek", ignore = true) // defaults in the entity; not client-settable
@Mapping(target = "isDeleted", ignore = true) // archival is a dedicated endpoint
void applyCreateRequest(EmployeeCreateRequest request,
@MappingTarget Employee employee);

Read those ignores as the compiler-enforced documentation they are: each one names the field, and each comment names the other place responsible for it. Fourth, the update method the MappingDefaults javadoc pointed at — partial-update semantics declared on the method:

@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)

Fifth, the inverse posture for a narrow write surface — instead of ignoring a few fields, ignore everything and allowlist:

// Employee profile update: self-service writes exactly two fields.

@BeanMapping(ignoreByDefault = true,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
@Mapping(target = "address", source = "address")
@Mapping(target = "phoneNumber", source = "phoneNumber")
void updateProfile(EmployeeProfileUpdateRequest request,
@MappingTarget Employee employee);

And the username ignored back in shape one? A default method closes the loop, because the username comes from the repository, not from the Employee entity:

default EmployeeDto toDto(Employee employee, String username) {
EmployeeDto dto = toDto(employee); // Calls the MapStruct-generated method to
// map Employee to EmployeeDto
dto.setUsername(username);
return dto;
}

That is the convention the coding standards generalize: "Fields derived from repository data (counts, resolved associations) stay in the service and reach the mapper as extra source parameters or a default-method overload."

The four hand-written survivors​

Not everything maps through MapStruct, and the exceptions are enumerated, not discretionary. From ../coding-standards.md:

Hand-written mapping survives in exactly four places, each commented at the call site: JPQL constructor-expression queries, dynamic Tuple/multiselect projections, ResultSet row mappers, and List<Object[]> aggregates.

The common thread: in all four, the "source" is not an entity — it is query output that never becomes an object MapStruct could read. You met the two Tuple projections in lesson 07. For a ResultSet row mapper, look at the HR-analytics drill-down — the backend behind the drawer decoded in ../frontend-101/18-drilldown-drawer-decoded.md. HrDrilldownQueries builds the SQL, and HrAnalyticsServiceImpl maps rows by hand because a row is all there is:

List<DrilldownRecordDto> rows = jdbcTemplate.query(
"SELECT * FROM (" + query.sql() + ") t" + where + " LIMIT ? OFFSET ?",
(rs, n) -> new DrilldownRecordDto(rs.getString("c1"), rs.getString("c2"), rs.getString("c3"),
rs.getObject("entity_id", Integer.class)),
pageArgs.toArray());

The standards close the loophole in one sentence, and it is the sentence to carry out of this lesson:

If you are adding a private XDto toDto(X) to a service impl, you are in one of the four cases above — or you are doing it wrong.

Where this shows up in MotorPH​

Recap​

  • Every mapper is an interface in mapper/ with @Mapper(config = MappingDefaults.class), one per module prefix, injected like any other collaborator — and the pom's -Amapstruct.unmappedTargetPolicy=ERROR backstops the mapper that forgets the config line.
  • Unmapped targets and lossy conversions fail the compile. Skipping a field takes an explicit ignore = true with a reason a reader can see; unmapped sources are normal and stay IGNORE.
  • Partial-update semantics live on the method, never in the shared config — @BeanMapping(nullValuePropertyMappingStrategy = IGNORE) per update method, because other flows write null on purpose.
  • qualifiedByName is always explicit — an Entity-to-String mapping chosen silently is exactly the surprise these policies exist to prevent.
  • Hand-written mapping survives in exactly four places — JPQL constructor expressions, Tuple projections, ResultSet row mappers, List<Object[]> aggregates — and a private toDto outside them is wrong by definition.

One preview before you go: in tests the rule is @Spy new EmployeeMapperImpl(), never @Mock — lesson 21 shows why a mocked mapper would quietly defeat everything this lesson set up.

Next: 10 — The filter chain, in execution order.