Quickstart: Your First 422

From a blank project to an endpoint that answers with every bad field at once.

This is the shortest true path: five steps, each one taken from the example application in this repository, so what you read is what the build compiles and runs. Nothing here is a sketch.

What You'll Learn

  • Add the processor to a build, and meet the one prerequisite that will stop you
  • Declare a mapping as an interface with no body, and add a leaf where a field needs checking
  • Return the parse result straight from a controller, with no error-handling code in between
  • Read the 422 your client receives, field by field
  • Prove the mapping in one test call

See Example Code

The spec, the endpoint and the test are included from the hkj-spring example application: UserMapping.java, UserController.java, UserParseWebMvcSliceTest.java


Before you start

Java 25, with preview features enabled

Higher-Kinded-J is built on Java 25 today, and parts of it use preview language features. Preview ties that to one release: javac accepts --enable-preview only for the JDK it is running on, and some of the library's own class files load on no other version. A later JDK therefore waits on the library moving to it.

Decide this first. On a team still on Java 21, or one whose platform is already moving past 25, that is a conversation rather than a dependency bump. The Quickstart has the details, and the Gradle plugin below sets the flags for you.

1. Add the plugin

plugins {
    id("io.github.higher-kinded-j.hkj") version "LATEST_VERSION"
}

That single line wires the dependencies, the annotation processor, -parameters and the preview flags. For a hand-rolled build, or for Maven, see Manual setup. To answer with an HTTP response at the end of step 3, add the Spring starter, hkj-spring-boot-starter.

2. Declare the mapping

A mapping is an interface you own. Name the two records it maps between, and the processor writes the rest. Where a field needs converting or checking, add a leaf: one method, named after the domain component, returning the conversion.

@GenerateMapping
public interface UserMapping extends MappingSpec<User, UserDto> {

  /**
   * Validates a present email, keeping it a {@code String} (an explicit leaf wins over identity).
   *
   * <p>The bean-shaped {@code parse} null-guards the read, so this leaf never sees {@code null}
   * (and {@code ValidatedPrism.parse} rejects a null source outright by contract); the lambda's own
   * null test is defence in depth. A malformed present value becomes a located error.
   */
  default ValidatedPrism<String, String> email() {
    return ValidatedPrism.of(
        raw ->
            raw != null && raw.contains("@")
                ? Validated.validNel(raw)
                : Validated.invalidNel(FieldError.of("not a valid email address")),
        email -> email);
  }

  /** Validates a present first name as non-blank; the null test is defence in depth as above. */
  default ValidatedPrism<String, String> firstName() {
    return ValidatedPrism.of(
        raw ->
            raw != null && !raw.isBlank()
                ? Validated.validNel(raw)
                : Validated.invalidNel(FieldError.of("must not be blank")),
        firstName -> firstName);
  }

If you know MapStruct

The spec is the @Mapper interface and UserMappingImpl is the UserMapperImpl it generates: the same shape, with three differences. One declaration gives both directions. A conversion is declared per component and never inferred from the types. And the inbound direction returns every bad field rather than throwing on the first.

The wire here is a getter/setter bean, because that is what a generated API client hands you; Bean-Shaped Wires covers that shape in full. A record wire works exactly the same way, and is what Record Mapping Basics teaches. The stock conversions, for UUIDs, dates, enums and money, are in Standard Codecs, so most boundaries need no hand-written leaf at all.

3. Return the parse result from a controller

parse gives you either the domain value or every located failure. With the starter on the classpath, a controller returns that result as-is:

  @PostMapping("/parse")
  public Validated<NonEmptyList<FieldError>, User> parseUser(@RequestBody UserDto dto) {
    return userCodec.parse(dto);
  }

Two things that catch people here

FieldError is Higher-Kinded-J's, not Spring's org.springframework.validation.FieldError: an IDE will offer the wrong import. And the spec interface is not the thing you call. Inject the surface (ValidatedPrism<UserDto, User>, registered as shown in Injecting and testing) or call UserMappingImpl.INSTANCE in the caller. Never declare that constant on the spec itself: it can read null.

4. Read the 422

Post a body with two bad fields, an email that is not one and a blank first name:

{"id": "7", "email": "not-an-email", "firstName": "", "lastName": "Hopper"}

One response comes back, carrying both, each located by the field it belongs to:

{
  "valid": false,
  "errorCount": 2,
  "errors": [
    { "path": "email",
      "segments": ["email"],
      "message": "not a valid email address" },
    { "path": "firstName",
      "segments": ["firstName"],
      "message": "must not be blank" }
  ]
}

The status is 422 Unprocessable Content, and it is configurable: an API contract that promises 400 sets hkj.web.validation-field-error-status. The 422 leg is the full story.

A field the client leaves out is a located error too, must not be null, in the same response rather than an exception. One rule covers both: Null has an address, not a stack trace.

Type the wire loosely

Jackson binds the request before parse ever runs. A wire field typed LocalDate, an enum or an int is Jackson's to reject, and it answers with its own 400 and no field path, so the located response above never happens for that field. Keep a wire field a String wherever a codec or a leaf converts it, and let the mapping do the checking.

5. Prove it

The example application asserts exactly that response, in a slice test:

  @Test
  @DisplayName("two bad fields come back as ONE 422 listing both by path")
  void twoBadFieldsAccumulateInOne422() throws Exception {
    mockMvc
        .perform(
            post("/api/users/parse")
                .contentType(MediaType.APPLICATION_JSON)
                .content(
                    """
                    {"id":"7","email":"not-an-email","firstName":"","lastName":"Hopper"}
                    """))
        .andExpect(status().isUnprocessableContent())
        .andExpect(jsonPath("$.valid").value(false))
        .andExpect(jsonPath("$.errorCount").value(2))
        .andExpect(jsonPath("$.errors[*].path").value(containsInAnyOrder("email", "firstName")))
        .andExpect(
            jsonPath("$.errors[?(@.path=='email')].message").value("not a valid email address"))
        .andExpect(jsonPath("$.errors[?(@.path=='email')].segments[0]").value("email"))
        .andExpect(jsonPath("$.errors[?(@.path=='firstName')].message").value("must not be blank"));
  }

For the mapping itself, one call checks its laws, without a web layer:

    MappingLaws.assertMappingLaws(
        CustomerMappingImpl.INSTANCE.asValidatedPrism(),
        new CustomerDto("Ada", "ada@example.org"), // parses
        new CustomerDto("Bob", "not-an-email")); // must not parse

Where next

Hands-On Learning

Practise the generated boundary in Tutorial 26: build, parse, read the located errors, and law-check the result.


Previous: Mapping at the Boundary Next: Record Mapping Basics