Coming from MapStruct and Bean Validation

Your existing vocabulary, translated, and the cases where you should keep what you have.

Most readers arrive already mapping DTOs, with a @Mapper interface and a set of @Valid constraints. Almost every habit carries over; three do not, and one of them is a trap. This page translates both vocabularies and marks where the analogy breaks.


From MapStruct

The shapes are close enough that a first spec usually reads like the mapper it replaces.

MapStructHereNote
@Mapper interface M with Mappers.getMapper(M.class)@GenerateMapping interface M extends MappingSpec<Domain, Wire>, then MImpl.INSTANCEone declaration gives both directions
@Mapping(target = "name", source = "fullName")@MapField(to = "fullName") String name();the method is named after the domain component
Built-in String to UUID, enum or date conversiona default leaf returning StandardCodecs.uuid() and friendsnever implicit: a conversion exists where a spec declares it
uses = UuidMapper.classthe same leaf, shared through a mix-in vocabularyshared by name
@Named plus qualifiedByNamethe leaf is the named method
expression = "java(...)", or @AfterMapping filling a target fielda default method returning Getter<Domain, T>a derived field, build-side only
uses = CustomerMapper.class for a nested typenothing: a spec for the pair nests automaticallyfailures gain the outer component's path
@Mapping(target = "a.b", source = ...), deep target paths@Flatten, one leveldeeper flattening stays MapStruct's
@SubclassMappingsealed dispatchexhaustive both ways, or it does not compile
@MappingTarget plus NullValuePropertyMappingStrategy.IGNOREUpdateSpec and updateFrom(wire).apply(current)returns Validated; nested objects replace wholesale
@MappingTarget for a dense write-backa projection's patch(domain, wire) or asLens()every projected field written, and validated
ignore = true on a target@Unmapped on a bean accessor; a narrower wire is simply a projection
unmappedTargetPolicy = ERRORalways onan unmapped wire component is a compile error
@InheritInverseConfigurationnot neededthe inverse is the same declaration
componentModel = "spring"a @Bean of ValidatedPrism<Wire, Domain> from asValidatedPrism()Injecting and testing
A conversion that throwsa located FieldError in an accumulating parsenothing throws for bad data
A mutable JPA entity as the targetnot supported: the domain must be a recordkeep MapStruct here

The three that do not carry over

  1. No implicit conversions. MapStruct converts String to UUID because it can. Here a conversion exists only where a spec declares it, so a first migration adds a leaf per converted field. A mix-in vocabulary pays that back: declare the house conversions once, extend them everywhere.
  2. Error paths are domain-named. A renamed field reports at the domain's name, not the wire's. Clients that map errors onto their own payload keys need the rename applied in reverse.
  3. Mappers.getMapper has no equivalent on the interface. Declaring M MAPPER = MImpl.INSTANCE; on the spec compiles and then reads null, intermittently, because of the class-initialisation cycle: bind it in the caller.

Migrating one pair

  1. Write the spec beside the existing mapper. Both can live in one module.
  2. Assert they agree on a golden set of inputs, then delete the old assertions.
  3. Add one MappingLaws call, which checks the round trip the old mapper never promised.
  4. Switch the controller to return parse and let the 422 leg render the failures.

From Bean Validation

@Valid already accumulates errors, and they already carry field names. The difference is what you hold afterwards: a set of violations about a DTO, against a domain value that has been built and checked in one step.

Bean ValidationHere
@NotNull on a wire fieldautomatic: every reference parse reads is null-guarded, and a null is a located error
@Valid on a nested objectautomatic: a nested spec parses it, and failures gain its component's path
@Valid on a collection's elementsautomatic: containers lift their element's leaf or spec, and locate by index or key
@Email, @Pattern, @Size, @Mina leaf on that component, or a stock codec
A cross-field @AssertTruethe domain record's own compact constructor: its refusal is located too
Set<ConstraintViolation<T>>Validated<NonEmptyList<FieldError>, Domain>, which carries the built value on success
violation.getPropertyPath()FieldError.path(), or its structured segments
@ControllerAdvice translating violationsnothing: return the parse result and the starter renders it

Validate, do not normalise

A leaf must accept exactly what it renders, so trimming or case-folding inside one breaks the law the round trip rests on: the value would no longer rebuild to what the client sent. Normalise before the boundary, or model the normalised form as its own domain type with its own canon.

Two constraints have no equivalent, and both are deliberate. A constraint group (groups = ...) has no counterpart: a spec is one contract, and a second contract is a second spec, which is how the sparse PATCH tier works. And a validator that reaches a database or another service does not belong in a leaf, which is a pure function; that check belongs after the boundary, on the effect railway.


Where next


Previous: Mapper at a Glance Next: Rules and Limits