Compile-time vs Runtime DTO Mapping
A DTO layer is meant to stop a change in your persistence model from becoming a change in your public contract. Whether it actually does depends on something most teams never decide explicitly: when the mapping between the two is resolved. Resolve it at runtime and a field rename becomes a null in a JSON payload. Resolve it at build time and the same rename stops the compiler.
The claim under test
"We use DTOs, so our layers are decoupled." It is said in almost every design review, and it is one of those statements that is true of the intent and frequently false of the code. A DTO decouples nothing on its own. What decouples is the boundary the DTO marks, and a boundary only exists if something enforces it.
Fowler's catalogue entry is a single sentence, and it is worth noticing how narrow it is:
An object that carries data between processes in order to reduce the number of method calls.
Nothing in that definition mentions layering, encapsulation, or hiding your entities. The pattern is about a process boundary. When we say "DTO" in a Spring REST service we mean the same thing in spirit: the HTTP boundary is a process boundary, and the shape you publish across it is a contract with someone you cannot refactor. That contract is the whole value. Everything else on this page is about protecting it.
Here is the shape the claim usually has in practice. One DTO type, used in both directions, converted by a reflective mapper, in a controller that also owns the conversion:
@RestController
@RequestMapping("/api/areas")
public class AreaController {
private final ModelMapper modelMapper;
private final AreaService areaService;
// Same type in and out. The request contract and the response contract
// are now one object, and neither of them can move without the other.
@PostMapping
public AreaDto create(@RequestBody AreaDto dto) {
AreaOfInterest entity = modelMapper.map(dto, AreaOfInterest.class);
return modelMapper.map(areaService.create(entity), AreaDto.class);
}
}
There are three independent defects in those fifteen lines, and they are the three subjects of this page: the mapping is resolved at runtime, the input and output contracts are the same type, and the layer that performs the mapping has not been decided, only assumed.
Two mapping strategies, and where the error surfaces
Both strategies produce the same runtime behaviour when everything matches. They differ entirely in what happens when something stops matching, which is the only interesting case, because it is the case that actually occurs.
A reflective mapper inspects both types at runtime and infers the correspondence. ModelMapper states its own approach plainly, and the description is fair: this is a feature, not an accident:
The goal of ModelMapper is to make object mapping easy, by automatically determining how one object model maps to another, based on conventions, in the same way that a human would — while providing a simple, refactoring-safe API for handling specific use cases.
Take the "refactoring-safe" claim seriously before disagreeing with it, because it is half true and the half that is true matters. ModelMapper's explicit API, the typed property maps you write by hand for specific cases, genuinely is refactoring-safe: rename a field and the compiler objects. The claim does not extend to the convention-based matching that is the library's headline feature and the reason most teams adopt it, and that is where the fields silently stop lining up. The distinction is between the part of the tool you are told about and the part you actually use.
"In the same way that a human would" is exactly right, and exactly the problem. A human inferring a correspondence can be wrong, and a convention evaluated at runtime has no opportunity to tell you it was wrong until the request arrives. MapStruct makes the opposite trade: it resolves the same correspondence during compilation:
This implementation uses plain Java method invocations for mapping between source and target objects, i.e. no reflection or similar.
Now apply the change that actually happens: someone renames a field on the entity. Not a redesign, not a migration: a rename, of the kind an IDE performs in a keystroke and a reviewer approves without comment.
// One field is renamed on the entity. Nothing else is touched.
public class AreaOfInterest {
// private String name; ← removed
private String title; // ← added
}
// Reflective mapping: compiles, starts, passes health checks, serves traffic.
// AreaDto.name is now silently null on every response, indefinitely.
AreaDto dto = modelMapper.map(entity, AreaDto.class);
// Annotation processing: the module does not compile.
// error: Unmapped target property: "name".
// The artifact that would have served the null was never produced.
The reflective case is worse than an exception would be. An exception is a signal. A silently absent field is a change to your published contract that no test asserted, no log recorded, and no alert fired on, discovered eventually by a client, in the form of a bug report about a blank column.
A hand-written toResponse(entity) method is also compile-time
safe. Rename the field and it stops compiling, exactly like the generated one. Manual
mapping is verbose and occasionally the right answer, when the transformation is genuinely
interesting, a generator is worse than a method. The distinction that matters is not
generated versus hand-written; it is resolved by the compiler versus resolved
by reflection at request time. Explicitness is fine. Deferral is the defect.
What MapStruct actually generates
Annotation processing has a reputation for magic, which is unfortunate, because it is the least magical option on the table: it is the only one whose output you can open in an editor. You declare an interface with no bodies:
@Mapper(componentModel = "spring")
public interface LayerMapper {
LayerResponse toResponse(Layer source);
}
During compilation the processor writes an implementation next to your compiled classes,
under target/generated-sources. This is the entire mechanism, and it is worth
reading once, because seeing it removes the last reason to be nervous about it:
package hub.layer.mapper;
import hub.layer.dto.LayerResponse;
import hub.layer.model.Layer;
import javax.annotation.processing.Generated;
import org.springframework.stereotype.Component;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2026-01-14T09:41:22+0000",
comments = "version: 1.5.5.Final, compiler: javac, environment: Java 17"
)
@Component
public class LayerMapperImpl implements LayerMapper {
@Override
public LayerResponse toResponse(Layer source) {
if ( source == null ) {
return null;
}
String id = null;
String name = null;
id = source.getId();
name = source.getName();
LayerResponse layerResponse = new LayerResponse( id, name );
return layerResponse;
}
}
That is all of it. A plain Java class, a null guard, a sequence of getter calls, a constructor
invocation. No proxies, no reflection, no Field.setAccessible, no configuration
cache warming on first request, nothing to step over in a debugger. The @Component
on line 13 is what componentModel = "spring" produced: the implementation is an
ordinary bean, so it injects like any other and can be constructor-injected into a controller.
Open target/generated-sources the first time a mapper misbehaves, before
reaching for the documentation. Every question of the form "did it map that field?" is
answered by looking. That property, that the answer is readable rather than
inferable, is most of what you are buying.
The one setting nobody sets
Compile-time resolution buys you nothing if you tell the compiler not to care. MapStruct has a per-mapper policy governing what happens when a property on the target type receives no value, and the documented values are exact:
ERROR: any unmapped target property will cause the mapping code generation to fail
The same table records the default. It is WARN. That single fact accounts for a
remarkable amount of production behaviour: a team adopts MapStruct, gets compile-time type
safety on the fields it did map, and keeps the exact failure mode it switched away from on
the fields it did not, because a warning in a build log that prints four hundred lines is
indistinguishable from silence.
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR, // a gap fails the build
unmappedSourcePolicy = ReportingPolicy.WARN // extra source fields are usually fine
)
public interface AreaMapper {
AreaResponse toResponse(AreaOfInterest source);
// Every target property must be accounted for: mapped, or ignored on purpose.
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
AreaOfInterest toEntity(CreateAreaRequest request);
}
Note the asymmetry on lines 3 and 4, because it is deliberate rather than a style choice.
An unmapped target property is a hole in something you publish: a field a client
will receive as null, or a column that will be written as NULL. An
unmapped source property is merely information you chose not to forward, which is
normally the correct behaviour at a boundary whose entire job is to expose less than the
entity holds. Errors on the target, warnings on the source.
[ERROR] AreaMapper.java:[8,20] Unmapped target property: "name".
[ERROR] Mapping from AreaOfInterest to AreaResponse.
[INFO] BUILD FAILURE
Three lines, a file, a line number, and a property name, produced before the artifact existed. The equivalent information in the reflective world is a support ticket.
ERROR rather than WARN at an API boundary
WARN is the right default for a library that cannot know what its mappers are
for; plenty of mappings are deliberately partial. At an API boundary that
reasoning inverts. The target type is a published contract, so an unfilled property is not
an incomplete mapping. It is a contract you are advertising and not honouring. There is no
state of the world in which shipping that silently is preferable to a red build. Set it on
every mapper, or once in a @MapperConfig that all of them reference, and set it
on the day you adopt the tool. Retrofitting ERROR onto thirty existing mappers
is a genuinely unpleasant afternoon.
The performance argument, stated honestly
MapStruct lists speed first among its advantages over dynamic mapping frameworks: "Fast execution by using plain method invocations instead of reflection". Mechanically the claim is uncontroversial. Reflective property resolution does per-request work that a generated call does once, at compile time: walking properties, matching names against conventions, invoking accessors indirectly. Reflective libraries generally cache what they have already resolved, so the steady-state cost is lower than the first call suggests.
Now the honest part, which most articles on this subject omit. For the overwhelming majority of CRUD endpoints, mapping is not your bottleneck. An endpoint that makes a database round trip, serialises JSON, and crosses a network is not going to be measurably changed by how twelve fields were copied. Anyone who tells you they made an API "fast" by swapping mappers has almost certainly not profiled it.
So do not adopt annotation processing for throughput. Adopt it because a rename becomes a compiler error instead of a null, because the mapping is a file you can read, and because the cost of the guarantee is zero at runtime. Speed is a side effect worth having and a poor reason to decide. If mapping genuinely is your hot path, a high-volume transform loop rather than a request handler, measure it in your own workload rather than quoting anyone's benchmark, including this page's.
Two directions are two contracts
Reusing one DTO for both request and response is the single most productive source of churn in a REST codebase, and it is worth being precise about the mechanism, because "it's messy" undersells it. A request type is constrained by what a client is permitted to send. A response type is constrained by what a client is permitted to see. Those two sets are not the same set, and they do not evolve together.
/** One type, both directions. Every field must satisfy both contracts. */
@Data
public class AreaDto {
private String id; // server-assigned, must be ignored on input
private String name;
private String layerId;
private String layerName; // server-derived, must be ignored on input
private Instant createdAt; // server-assigned, must be ignored on input
private String ownerEmail; // added for one admin screen; now on every response
}
Every field the response needs, the request must now tolerate, and "tolerate" means someone
wrote defensive code, or forgot to. A client can POST an id and a
createdAt; either the mapper ignores them (invisibly, by convention) or it does
not (a mass-assignment bug). And when a single admin screen needs one extra field, that field
appears on every response the type serves. Then someone hides it again for the public
caller. Then someone else needs it back. That loop of "modified DTO to include field X" and
"modified DTO to not include field X" is not sloppiness; it is the inevitable output of one
type serving two contracts with opposing requirements.
Splitting the type ends the loop permanently, because the two contracts stop sharing a constraint:
/** In. Only what a client may send. Server-owned fields are absent by design. */
public record CreateAreaRequest(
@NotBlank String name,
@NotNull GeometryDto geometry,
@NotBlank String layerId
) {}
/** Out. Only what a client may see. Free to grow without touching the input. */
public record AreaResponse(
String id,
String name,
String layerId,
String layerName,
Instant createdAt
) {}
Notice what the split makes impossible rather than merely discouraged. A client
cannot send id, because the type has no such component, not because a mapper was
configured to ignore it. Mass assignment is now a compile error rather than a policy. And the
response can gain a field tomorrow with zero effect on what callers are allowed to post.
Records make this cheap
Java 17 removes the usual objection, which was always that two types cost twice the boilerplate. A record declares the contract in its header and the compiler supplies the rest:
A record's fields are final because the class is intended to serve as a simple "data carrier".
That single property is worth more at a boundary than it first appears. A mutable DTO can be
modified after the mapper produced it and before the serialiser reads it, which means the
shape you audited is not necessarily the shape you sent. A record cannot. You also stop
needing Lombok on DTOs entirely: no @Data, no @Setter, no
@NoArgsConstructor added because a framework demanded one. Accessors,
equals, hashCode and toString are generated by
javac rather than by a second annotation processor, and MapStruct maps to records
natively by invoking the canonical constructor, exactly as the generated class above shows.
LocalDTO is emphatic: DTOs "are called Data Transfer Objects because their whole purpose is to shift data in expensive remote calls", and locally "they are actually harmful". This page argues for more DTO types, so the tension is worth resolving rather than ignoring. Fowler's target is a DTO interposed inside a local call stack, bought at the cost of a mapping layer that buys nothing back. An HTTP endpoint is not that: it is a genuine process boundary with a published contract, and it is also the presentation-mismatch case he explicitly allows, where the model the screen needs differs from the domain model. The rule that follows from both readings is the same one: DTOs at the boundary, domain objects inside it, and no DTO that exists purely to sit between two of your own classes. martinfowler.com/bliki/LocalDTO.html
Mapping belongs in exactly one layer
This is the rule that survives whichever mapping tool you pick, and the one that decides whether the tool helped. If the controller maps and the service maps, the boundary is not in a different place. It is in no place. There are now two implementations of the same correspondence, they will diverge, and the divergence will be invisible because both compile.
Two placements are defensible. The third column is the defect:
| Question | Map in the controller | Map in the service | Map in both |
|---|---|---|---|
| Who knows the DTO types | Web layer only | Application layer only | Everyone |
| Service method signature | Domain types | Request / response types | Whichever the caller had |
| Where a rename surfaces | One file | One file | Two: one gets fixed |
| Testable without HTTP | Yes: service takes domain types | Yes, but tests speak DTO | Depends on the entry point |
| Verdict | Valid | Valid | Undefined boundary |
The examples on this page put mapping in the controller, because it keeps DTOs out of the domain entirely and leaves the service testable without any web type in sight. But the choice matters far less than making it once, writing it down, and enforcing it in review. A codebase that consistently maps in the service is in good shape. A codebase that does both is not, and no mapping library will tell you so.
What this looked like in production
The pair is the lesson. The same engineers wrote both, and the second is a deliberate correction of the first, which makes it an unusually clean natural experiment.
The older estate. All mapping went through ModelMapper, reflective and resolved at runtime. There was no MapStruct anywhere in it. Three properties compounded:
- DTOs were mutable JavaBeans, Lombok
@Data/@Setter, not records, so nothing prevented a DTO being altered after mapping and before serialisation. - The same DTO type was reused for request and response, in both directions. Its history contains exactly the churn the mechanism predicts: repeated commits modifying a DTO to include, and then not to include, a particular field.
- Mapping happened in both the controller and the service, and both injected a
ModelMapper. The mapping boundary was therefore undefined: no layer owned the conversion, so both drifted.
The newer estate. The team moved to MapStruct 1.5.5.Final, configured
estate-wide with the compiler argument
-Amapstruct.defaultComponentModel=spring so that every generated
implementation is a Spring bean without per-mapper annotation. The adoption was real and
substantial: 30 @Mapper interfaces, distributed
14 / 8 / 4 / 2 / 2 across five services.
That is the correction working. Reflection resolved at runtime was replaced by mappers the compiler checks, and the same team that wrote the first arrangement chose the second deliberately. The estate has since standardised on it: MapStruct is the mapping tool, and the conversion is owned by one layer instead of being shared between the controller and the service. Adopting the tool is the easy half. The boundary rule is the half that decides whether it helps, which is why it is worth writing down rather than leaving to habit.
…/mapper/*Mapper.java · 30 interfaces pom.xml · -Amapstruct.defaultComponentModel=spring …/controller/*Controller.java · ModelMapper injected …/service/*ServiceImpl.java · ModelMapper injectedDoing it properly
Three tabs: the arrangement as found, the repair, and the code the compiler writes for you, which is the thing that demystifies the repair. Read the third tab last and note that there is nothing in it you could not have written by hand; the point is that you cannot forget to update it.
/**
* Mapping strategy: ModelMapper, correspondence resolved by convention at runtime.
* Boundary: undefined. The controller maps, and so does the service.
*/
@RestController
@RequestMapping("/api/areas")
@RequiredArgsConstructor
public class AreaController {
private final ModelMapper modelMapper; // mapping site 1
private final AreaService areaService;
@PostMapping
public AreaDto create(@RequestBody AreaDto dto) {
AreaOfInterest entity = modelMapper.map(dto, AreaOfInterest.class);
AreaOfInterest saved = areaService.create(entity);
return modelMapper.map(saved, AreaDto.class);
}
@PostMapping("/import")
public AreaDto importArea(@RequestBody AreaDto dto) {
return areaService.createFromDto(dto); // maps somewhere else entirely
}
}
@Service
@RequiredArgsConstructor
public class AreaService {
private final ModelMapper modelMapper; // mapping site 2
private final AreaRepository repository;
public AreaOfInterest create(AreaOfInterest entity) {
return repository.save(entity);
}
public AreaDto createFromDto(AreaDto dto) {
AreaOfInterest entity = modelMapper.map(dto, AreaOfInterest.class);
return modelMapper.map(repository.save(entity), AreaDto.class);
}
}
/* ── contracts: two types, because there are two contracts ─────────── */
/** In. Only what a client may send. */
public record CreateAreaRequest(
@NotBlank String name,
@NotNull GeometryDto geometry,
@NotBlank String layerId
) {}
/** Out. Only what a client may see. */
public record AreaResponse(
String id,
String name,
String layerId,
String layerName,
Instant createdAt
) {}
/* ── the mapper: a declaration, verified by javac ──────────────────── */
@Mapper(
componentModel = "spring",
uses = GeometryMapper.class,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface AreaMapper {
@Mapping(target = "layerName", source = "layer.name")
AreaResponse toResponse(AreaOfInterest source);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "layer", ignore = true)
AreaOfInterest toEntity(CreateAreaRequest request);
}
/* ── one mapping layer: the controller. The service never sees a DTO. */
@RestController
@RequestMapping("/api/areas")
@RequiredArgsConstructor
public class AreaController {
private final AreaMapper mapper;
private final AreaService areaService;
@PostMapping
public AreaResponse create(@Valid @RequestBody CreateAreaRequest request) {
AreaOfInterest saved = areaService.create(mapper.toEntity(request));
return mapper.toResponse(saved);
}
}
package hub.area.mapper;
import hub.area.dto.AreaResponse;
import hub.area.dto.CreateAreaRequest;
import hub.area.model.AreaOfInterest;
import hub.area.model.Layer;
import java.time.Instant;
import javax.annotation.processing.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2026-01-14T09:41:22+0000",
comments = "version: 1.5.5.Final, compiler: javac, environment: Java 17"
)
@Component
public class AreaMapperImpl implements AreaMapper {
@Autowired
private GeometryMapper geometryMapper;
@Override
public AreaResponse toResponse(AreaOfInterest source) {
if ( source == null ) {
return null;
}
String id = source.getId();
String name = source.getName();
String layerId = source.getLayerId();
String layerName = areaOfInterestLayerName( source );
Instant createdAt = source.getCreatedAt();
return new AreaResponse( id, name, layerId, layerName, createdAt );
}
@Override
public AreaOfInterest toEntity(CreateAreaRequest request) {
if ( request == null ) {
return null;
}
AreaOfInterest areaOfInterest = new AreaOfInterest();
areaOfInterest.setName( request.name() );
areaOfInterest.setGeometry( geometryMapper.toGeometry( request.geometry() ) );
areaOfInterest.setLayerId( request.layerId() );
return areaOfInterest;
}
private String areaOfInterestLayerName(AreaOfInterest source) {
Layer layer = source.getLayer();
if ( layer == null ) {
return null;
}
return layer.getName();
}
}
Two details in the generated tab repay attention. The nested source path
layer.name became a private helper with its own null guard, on line 53, and that is
a null-pointer bug you did not have to remember. And setId never appears, because
the mapper declared ignore = true; with ReportingPolicy.ERROR in
force, that omission had to be stated in the source rather than assumed.
When reflective mapping is genuinely fine
This is not a page against ModelMapper. It is a page about which side of the deploy an error lands on, and there are contexts where that question has a different answer because the blast radius is different.
- Internal tooling and admin utilities with a handful of known operators, where a null field is noticed in a minute and fixed in five.
- Test fixtures: building an object graph for an assertion, where the failure is the test failing, which is the desired behaviour anyway.
- Throwaway scripts and one-off data migrations that run once, under supervision, and are then deleted.
- Spikes and prototypes whose entire purpose is to answer a question before anyone depends on the answer.
The common factor: no published contract, a short feedback loop, and a human watching.
- Any public or partner-facing API. The response shape is a contract you cannot unilaterally change, so it must not be able to change by accident.
- Anything versioned. If
/v1exists, a silently dropped field is a breaking change that skipped the version negotiation entirely. - Service-to-service calls inside an estate: the consumer is a program, and programs do not report blank columns.
- Anything touching money, permissions or audit. A null where a value was expected is a correctness failure, not a display bug.
The common factor: someone downstream depends on the shape and will not be watching the build.
The rules
- Mapping at an API boundary is resolved at compile time: annotation processing or hand-written methods, never runtime reflection.
unmappedTargetPolicy = ReportingPolicy.ERRORon every@Mapper, or once in a shared@MapperConfig. Set it on day one.- Request and response are separate types. A client cannot send a server-owned field because the type has no such component.
- DTOs are records. Immutable, no Lombok, canonical constructor, and MapStruct targets them natively.
- Mapping happens in exactly one layer, the same layer in every service, written down somewhere a reviewer can point at.
- Every
ignore = trueis deliberate and, where it is not obvious, carries a comment saying why. - Someone on the team has opened
target/generated-sourcesand read a generated mapper end to end. - No entity type appears in a controller signature. A mapper cannot protect a boundary it is not standing on.
- No
mapper/package that contains no mappers, and no service quietly exempt from the estate's convention.
Canonical sources
- Martin Fowler: Data Transfer Object · the PoEAA definition, and the process-boundary framing the whole pattern rests on.
- Martin Fowler: LocalDTO · the argument against DTOs inside a local call stack, and the presentation-mismatch case he does allow.
- MapStruct reference guide · "no reflection or similar", the generated-implementation model, and the advantages claimed over dynamic mapping frameworks.
- MapStruct: configuration options · the exact semantics of
ERROR/WARN/IGNORE, and the documented default ofWARN. - Oracle: Record Classes (Java 17) · final fields, the canonical constructor, and the members
javacgenerates for you. - ModelMapper · the library describing its own convention-based, runtime-resolved approach, in its own words.