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 should just call it. This page covers the seams around that: what to register when you do want a Spring bean or a test double, how fakes work without mocks, and the diagnostics and limits that bound the feature.
- Why the spec interface deliberately injects nothing, and which surface to register per tier
- Test doubles as two-line
ValidatedPrism.of(...)values, no mocking framework involved - The width story: no component ceiling, chunked
fields()ladders past 16 legs - The remaining limits, each with a what/why/fix diagnostic
The width proof on this page is WideMappingLawsTest.java, and 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
A concrete or threaded Impl is a stateless pure function reached through statics (INSTANCE, instance()); an element-mapped Impl is an immutable value built by of(...), carrying its element prisms. The spec interface deliberately declares nothing either way (@Autowired UserMapping injects nothing useful, by design). When you do want a Spring bean or a test double, register the surface you consume, per tier:
| Tier surface | Injectable shape | From |
|---|---|---|
| parse-capable mapping | ValidatedPrism<UserDto, User> | UserMappingImpl.INSTANCE.asValidatedPrism() |
| build only | Function<User, UserDto> | UserMappingImpl.INSTANCE::build |
validated patch | BiFunction<User, UserCardDto, Validated<NonEmptyList<FieldError>, User>> | UserCardMappingImpl.INSTANCE::patch |
sparse updateFrom | Function<UserPatchDto, Edits.Accumulated<User>> | UserPatchMappingImpl.INSTANCE::updateFrom |
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 codecs for different pairs coexist without ceremony; only two codecs for the same pair need a @Qualifier. An element-mapped Impl (of(...)) carries its prisms as state: construct it once, in the @Bean method.
Fakes are values, not mocks. ValidatedPrism is sealed, so it cannot be hand-implemented, and a mocking framework cannot mock it either (sealed types are unmockable). That is the design, not a limitation: a test double is two lines of ValidatedPrism.of(...), here as the example app's real @WebMvcTest substitution:
/** A stub codec: every parse fails with one located error; build renders a fixed DTO. */
@TestConfiguration
static class RejectEverythingCodec {
@Bean
ValidatedPrism<UserDto, User> userCodec() {
return ValidatedPrism.of(
dto -> Validated.invalidNel(FieldError.of("rejected by the fake codec").at("email")),
user -> new UserDto());
}
}
The hkj-spring example app demonstrates the seam end to end: MappingConfiguration registers the codec, UserController's parse endpoint injects it, and UserParseFakeCodecSliceTest substitutes the fake above in a @WebMvcTest slice and asserts the located 422 it produces. The same controller's PATCH endpoint deliberately calls UserPatchMappingImpl.INSTANCE directly: injection buys substitution, not lifecycle, and a team comfortable calling the Impl directly (INSTANCE, instance(), or one shared of(...) instance) loses nothing.
Diagnostics and limits
There is no component ceiling. parse (and the validated patch, and @GenerateMerge's fallible merge) is assembled with Validated.fields() ladders, chunked and combined applicatively past 16 legs, so an externally fixed flat 20-or-30-field wire maps without grouping components into nested records, and behaves exactly like a narrow one (same located labels, 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();
assertThat(rendered(parsed))
.containsExactly(
"f1: must not be null", "f17: must not be null", "email: not an email address");
The only width bound left is the JVM's constructor parameter-slot limit on the record itself (254 components in practice, fewer with long/double), which javac enforces at the record declaration. The hand-written fields() ladder keeps its 16-field arity; wider hand-written assemblies nest sub-records.
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. The limits themselves are each explained where their feature lives; this table is the index:
| Limit | Where it is explained |
|---|---|
| Nested and sealed resolution sees specs in the same compilation; mix-ins must be plain, non-generic interfaces | Shared vocabulary |
Map components lift values only; keys are identity, so differing key types, raw Maps and wildcards are rejected | Nesting and containers |
A fallible projection emits the validated patch, never a fake asLens(); projections cannot carry derived fields | The Emission Tiers, Derived wire fields |
| Generic mappings come in exactly three forms and stay record-to-record | Generic Specs |
| Sparse PATCH is bean-only, wrapper-typed, and never deep-merges | Beans and Sparse PATCH |
- Register the surface, not the spec:
asValidatedPrism(),::build,::patch, or::updateFrom, per tier - Fakes are two-line values:
ValidatedPrism.of(...)replaces the mocking framework, by design - No component ceiling: chunked
fields()ladders carry flat 20-or-30-field wires; only the JVM's 254-slot record limit remains - 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: One 422, Every Bad Field