Injecting, Testing, and Diagnostics
Register the surface you consume, fake it with values, and read the processor's what/why/fix rejections.
A generated Impl is a pure function, so most code just calls it: CustomerMappingImpl.INSTANCE.parse(dto). This page covers the seams around that call. It says what to register when you do want a Spring bean or a test double, and how wide a mapped record may be.
- Register a mapping's surface, and inject exactly the part a consumer calls
- Replace a mapping in a test with a value, not a mock
The width proof on this page is WideMappingLawsTest.java, and the checkpoint proofs are TestingBookTest.java. The injection and fake snippets are included straight from the hkj-spring example app's MappingConfiguration and UserParseFakeCodecSliceTest - everything on this page is compiled and run by the build.
Injecting and testing generated mappings
Registering a mapping is like registering a Spring Converter<S, T> bean: the bean is a typed function, not a mapper class. Unlike a Converter, Spring never calls it for you. The controller calls parse, and bad input comes back as located errors rather than an exception.
Unlike a MapStruct mapper, the spec interface is never the bean. Nothing registers the spec or its Impl, and the spec declares no parse or build, so even a registered one would give you nothing to call. Register the surface instead: the typed value an Impl hands out. An Impl is reached through INSTANCE, or through instance() or of(...) for a generic spec.
| The consumer calls | Inject | From |
|---|---|---|
parse and build | ValidatedPrism<UserDto, User> | UserMappingImpl.INSTANCE.asValidatedPrism() |
parse alone | ValidatedParse<UserDto, User> | the same ValidatedPrism bean, or asValidatedParse() on a parse-only bean mapping |
build alone | Function<User, UserDto> | UserMappingImpl.INSTANCE::build, from any tier that has one |
a build-only bean mapping's build | ValidatedBuild<CustomerRequest, Customer> | CustomerRequestMappingImpl.INSTANCE.asValidatedBuild() |
a projection's set, over plain copies | Lens<Employee, EmployeeCardDto> | EmployeeCardMappingImpl.INSTANCE.asLens() |
a validated patch | BiFunction<Subscriber, SubscriberDetailsDto, Validated<NonEmptyList<FieldError>, Subscriber>> | SubscriberDetailsMappingImpl.INSTANCE::patch |
a sparse updateFrom | Function<UserPatchRequest, Edits.Accumulated<User>> | UserPatchMappingImpl.INSTANCE::updateFrom |
A ValidatedPrism is both a ValidatedParse and a ValidatedBuild, so one registered prism serves a consumer that asks for either. For a boundary, register asValidatedPrism() rather than asIso(), which a spec with a leaf does not have anyway. reverseGet has no guard, and neither does a lens's set. This is the hkj-spring example app's real configuration, included from source:
@Configuration
public class MappingConfiguration {
/**
* The user wire codec: parse a {@link UserDto} into the domain, or render a {@link User} back.
*
* @return the generated mapping's {@link ValidatedPrism} surface
*/
@Bean
public ValidatedPrism<UserDto, User> userCodec() {
return UserMappingImpl.INSTANCE.asValidatedPrism();
}
}
Spring resolves the full generic type, so surfaces for different pairs coexist without ceremony. Only two surfaces for the same pair need a @Qualifier. An element-mapped Impl carries its prisms as state, so construct it once, in the @Bean method.
Fakes are values, not mocks. Mockito refuses a sealed interface, and the three validated surfaces are sealed, so @MockitoBean refuses them too. The Impl is final, and it implements only the spec, never a surface, so a subclass cannot stand in either. None is needed: each sealed surface has an of factory that takes the functions it declares. The example app's controller only parses, so it asks for a ValidatedParse, and its @WebMvcTest fake is one function:
/** A stub codec: every parse fails with one located error. */
@TestConfiguration
static class RejectEverythingCodec {
@Bean
ValidatedParse<UserDto, User> userCodec() {
return ValidatedParse.of(
dto -> Validated.invalidNel(FieldError.of("rejected by the fake codec").at("email")));
}
}
A Lens fake is Lens.of(get, set), and a Function or BiFunction fake is a lambda. The controller depends on the surface's type alone, so production and a test fill it with different values:
flowchart TD
accTitle: The seam a surface makes
accDescr: In production the generated Impl's asValidatedPrism() bean fills the controller's ValidatedParse injection point. In a slice test a ValidatedParse.of value fills the same point.
I["UserMappingImpl.INSTANCE<br/>.asValidatedPrism()"] --> C["UserController asks for<br/>ValidatedParse<UserDto, User>"]
F["ValidatedParse.of(...)<br/>in a slice test"] -.-> C
classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
class I,F,C wire
In words: the controller asks for a ValidatedParse, which the production prism satisfies and a test's one-function fake replaces.
The hkj-spring example app runs the seam end to end. MappingConfiguration registers the prism, and UserController's parse endpoint injects it. UserParseFakeCodecSliceTest swaps in the fake, and asserts the located 422 it produces. The same controller's PATCH endpoint calls UserPatchMappingImpl.INSTANCE directly, and a team that calls the Impl this way (INSTANCE, instance(), or one shared of(...) instance) loses nothing. Injection buys a seam for tests, and nothing else.
A @WebMvcTest slice loads none of Higher-Kinded-J's auto-configuration, so import the three it needs, as UserParseFakeCodecSliceTest does: Slice Testing with @WebMvcTest.
To check the mapping itself rather than fake it, one MappingLaws call does it: Law-checked, in the repo and in your tests.
You can now register a mapping's surface, inject the part a consumer calls, and replace it in a test with a value. The rest of this page, how wide a record can be, is for when a wire is wide.
An app registers two beans: CustomerMappingImpl.INSTANCE.asValidatedPrism(), a ValidatedPrism<CustomerDto, Customer>, and CustomerViewMappingImpl.INSTANCE.asValidatedParse(), a ValidatedParse<CustomerView, Customer>. A signup handler's constructor asks for a ValidatedParse<CustomerDto, Customer>. What does Spring do?
- Fails at startup: two
ValidatedParsebeans forCustomerneed a@Qualifier - Fails at startup: no bean is declared as a
ValidatedParse<CustomerDto, Customer> - Injects the
CustomerMappingprism - Injects whichever of the two beans was declared first
Answer and why
Answer and why
3. Spring matches the full generic type. The prism is a ValidatedParse<CustomerDto, Customer>, and the other bean parses a different wire, so exactly one bean fits:
@Configuration
static class CustomerSurfaces {
@Bean
ValidatedPrism<CustomerDto, Customer> customerCodec() {
return CustomerMappingImpl.INSTANCE.asValidatedPrism();
}
@Bean
ValidatedParse<CustomerView, Customer> customerViewParser() {
return CustomerViewMappingImpl.INSTANCE.asValidatedParse();
}
}
record SignupHandler(ValidatedParse<CustomerDto, Customer> parser) {}
try (var context =
new AnnotationConfigApplicationContext(CustomerSurfaces.class, SignupHandler.class)) {
SignupHandler handler = context.getBean(SignupHandler.class);
assertThat(handler.parser()).isSameAs(context.getBean("customerCodec"));
assertThatValidated(handler.parser().parse(new CustomerDto("Ada", "ada@example.org")))
.hasValue(new Customer("Ada", new EmailAddress("ada@example.org")));
}
Where this lives: Injecting and testing generated mappings.
A service fills outbound requests through the ValidatedBuild<CustomerRequest, Customer> a build-only bean mapping provides. A unit test wants it to return a fixed CustomerRequest, without running the real mapping. What do you write?
@MockitoBean ValidatedBuild<CustomerRequest, Customer> requests;, withbuildstubbedValidatedBuild.of(customer -> fixed)- A subclass of
CustomerRequestMappingImplthat overridesbuild ValidatedParse.of(...), as the example app's fake does
Answer and why
Answer and why
2. Each sealed surface has an of taking the functions it declares, and a ValidatedBuild declares build. Mockito refuses the sealed interface, a ValidatedParse is not a ValidatedBuild, and the Impl is final and implements no surface:
CustomerRequest fixed = new CustomerRequest();
ValidatedBuild<CustomerRequest, Customer> requests = ValidatedBuild.of(customer -> fixed);
assertThat(requests.build(new Customer("Ada", new EmailAddress("ada@example.org"))))
.isSameAs(fixed);
assertThatThrownBy(() -> Mockito.mock(ValidatedBuild.class)) // what @MockitoBean calls
.hasMessageContaining("Sealed interfaces");
assertThat(ValidatedBuild.class.isAssignableFrom(ValidatedParse.class)).isFalse();
assertThat(ValidatedBuild.class.isAssignableFrom(CustomerRequestMappingImpl.class)).isFalse();
assertThat(Modifier.isFinal(CustomerRequestMappingImpl.class.getModifiers())).isTrue();
Where this lives: Injecting and testing generated mappings.
Diagnostics and limits
A mapping has no component ceiling. parse, the validated patch and a fallible @GenerateMerge are assembled with Validated.fields() ladders, chunked and combined past 16 fields. So a flat wire of 20 or 30 fields maps without grouping its components into nested records. It behaves exactly like a narrow one, with the same located labels and the same declaration-order accumulation across chunk boundaries:
// f1 fails in the first ladder, f17 and email in the second: one accumulated result,
// declaration order preserved across the boundary.
WideAccountDto wire =
new WideAccountDto(
null,
"v2",
"v3",
"v4",
"v5",
"v6",
"v7",
"v8",
"v9",
"v10",
"v11",
"v12",
"v13",
"v14",
"v15",
"v16",
null,
"v18",
"v19",
"not-an-email");
Validated<NonEmptyList<FieldError>, WideAccount> parsed =
WideAccountMappingImpl.INSTANCE.parse(wire);
assertThatValidated(parsed)
.isInvalid()
.hasFieldErrors(
"f1: must not be null", "f17: must not be null", "email: not an email address");
A @Flatten group is the one exception: it is a single ladder, so a group wider than 16 is not supported yet. The one width bound on a record is the JVM's limit on its constructor parameter slots, which javac enforces at the record declaration. That is 254 components in practice, fewer with long or double. A hand-written fields() ladder stops at 16 fields, so a wider hand-written assembly nests sub-records, or uses @GenerateAssembly.
Every rejection follows the processor's what/why/fix standard: the message states what is wrong, why the mapper needs it, and the code to write. Compiler Messages collects the common ones. The limits themselves are indexed in Find your limit, each linked to its rule.
- Register the surface, not the spec: inject the part a consumer calls, and let one
ValidatedPrismbean serve aValidatedParseor aValidatedBuildconsumer - Fakes are values: each sealed surface has an
offactory, so a test double is a function or two, never a mock - A mapping has no component ceiling: chunked
fields()ladders carry flat wires of 20 or 30 fields, up to the JVM's 254-slot record limit - Rejections are what/why/fix: every limit states what is wrong, why the mapper needs it, and the code to write
- Testing With hkj-test:
MappingLawsandassertThatFieldError - Spring Boot Integration: The example app the injection seam comes from
- Accumulating Assembly: The
fields()builder behind the generatedparse
Previous: Merge and Error Envelopes Next: Capstone: An Estate in Three Modules