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.

What You'll Learn

  • 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

See Example Code

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 callsInjectFrom
parse and buildValidatedPrism<UserDto, User>UserMappingImpl.INSTANCE.asValidatedPrism()
parse aloneValidatedParse<UserDto, User>the same ValidatedPrism bean, or asValidatedParse() on a parse-only bean mapping
build aloneFunction<User, UserDto>UserMappingImpl.INSTANCE::build, from any tier that has one
a build-only bean mapping's buildValidatedBuild<CustomerRequest, Customer>CustomerRequestMappingImpl.INSTANCE.asValidatedBuild()
a projection's set, over plain copiesLens<Employee, EmployeeCardDto>EmployeeCardMappingImpl.INSTANCE.asLens()
a validated patchBiFunction<Subscriber, SubscriberDetailsDto, Validated<NonEmptyList<FieldError>, Subscriber>>SubscriberDetailsMappingImpl.INSTANCE::patch
a sparse updateFromFunction<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&lt;UserDto, User&gt;"]
    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.

At the Spring boundary

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 ship now

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.

Checkpoint: which surface does Spring inject?

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?

  1. Fails at startup: two ValidatedParse beans for Customer need a @Qualifier
  2. Fails at startup: no bean is declared as a ValidatedParse<CustomerDto, Customer>
  3. Injects the CustomerMapping prism
  4. Injects whichever of the two beans was declared first

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.

Checkpoint: how do you fake a build-only surface?

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?

  1. @MockitoBean ValidatedBuild<CustomerRequest, Customer> requests;, with build stubbed
  2. ValidatedBuild.of(customer -> fixed)
  3. A subclass of CustomerRequestMappingImpl that overrides build
  4. ValidatedParse.of(...), as the example app's fake does

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.


Key Takeaways

  • Register the surface, not the spec: inject the part a consumer calls, and let one ValidatedPrism bean serve a ValidatedParse or a ValidatedBuild consumer
  • Fakes are values: each sealed surface has an of factory, 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

See Also


Previous: Merge and Error Envelopes Next: Capstone: An Estate in Three Modules