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>Mapperafter 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:
unmappedSourcePolicystays 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.
nullValuePropertyMappingStrategystays 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 asEmployeeMapper#updateEntitydoes.
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_IMMUTABLEmakes 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.
disableBuilderturns 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
qualifiedByNameexplicitly, 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/multiselectprojections,ResultSetrow mappers, andList<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
- ../../backend/src/main/java/com/motorph/payroll/mapper/MappingDefaults.java — the
@MapperConfigand its javadoc, this lesson's primary source. - ../../backend/src/main/java/com/motorph/payroll/mapper/PersonNames.java — the shared
@Namedqualifiers and thequalifiedByName-always rule. - ../../backend/src/main/java/com/motorph/payroll/mapper/EmployeeMapper.java — the specimen; the other 19 mappers in the same package follow its shapes.
- ../../backend/pom.xml — the annotation-processor flags, including the belt-and-braces
ERROR. - ../../backend/src/main/java/com/motorph/payroll/service/impl/HrDrilldownQueries.java and ../../backend/src/main/java/com/motorph/payroll/service/impl/HrAnalyticsServiceImpl.java — a living example of the
ResultSetrow-mapper exception. - ../coding-standards.md — the mapping convention and the four survivors, in the repo's own words.
- ../backend/annotations.md — the Lombok-
@Builderban thatdisableBuilderenforces; ../backend/README.md for the wider backend map.
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=ERRORbackstops the mapper that forgets the config line. - Unmapped targets and lossy conversions fail the compile. Skipping a field takes an explicit
ignore = truewith 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. qualifiedByNameis 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,
Tupleprojections,ResultSetrow mappers,List<Object[]>aggregates — and aprivate toDtooutside 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.