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.
- 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
The spec, the endpoint and the test are included from the hkj-spring example application: UserMapping.java, UserController.java, UserParseWebMvcSliceTest.java
Before you start
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);
}
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);
}
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.
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
- Shipping a record wire, and the declarations you will reach for: Record Mapping Basics
- UUIDs, dates, enums and money without writing a leaf: Standard Codecs
- A PATCH endpoint, where an absent field means leave unchanged: Sparse PATCH
- Judging the fit before adopting it: Mapper at a Glance
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