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.

What You'll Learn

  • 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

See Example Code

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 surfaceInjectable shapeFrom
parse-capable mappingValidatedPrism<UserDto, User>UserMappingImpl.INSTANCE.asValidatedPrism()
build onlyFunction<User, UserDto>UserMappingImpl.INSTANCE::build
validated patchBiFunction<User, UserCardDto, Validated<NonEmptyList<FieldError>, User>>UserCardMappingImpl.INSTANCE::patch
sparse updateFromFunction<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:

LimitWhere it is explained
Nested and sealed resolution sees specs in the same compilation; mix-ins must be plain, non-generic interfacesShared vocabulary
Map components lift values only; keys are identity, so differing key types, raw Maps and wildcards are rejectedNesting and containers
A fallible projection emits the validated patch, never a fake asLens(); projections cannot carry derived fieldsThe Emission Tiers, Derived wire fields
Generic mappings come in exactly three forms and stay record-to-recordGeneric Specs
Sparse PATCH is bean-only, wrapper-typed, and never deep-mergesBeans and Sparse PATCH

Key Takeaways

  • 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

See Also


Previous: Merge and Error Envelopes Next: Capstone: One 422, Every Bad Field