Capstone: An Estate in Three Modules

One boundary split across three modules: a shared vocabulary, a partner's client jar, and a service with a PATCH.

What You'll Learn

  • Split one boundary across three modules: a vocabulary with no processor, the client beans, and a service that holds the specs
  • Map a Lombok @Data bean and a generator-shaped bean from another module's compiled classes
  • Clear a field over PATCH with an Optional property, and see a nested object replaced whole
  • Prove each customer mapping with its laws, in one call

See Example Code

The code on this page is three Gradle modules, estate-api, estate-clients and estate-service, and their EstateBoundaryTest.java - the page includes them directly, so the build compiles and tests all of it.


The estate

The first capstone built one boundary in one module. An estate of services spreads the same boundary across several. One team publishes an API module that the others depend on. Partner teams ship client jars, whose beans an OpenAPI generator or Lombok wrote. Each service maps between those beans and its own domain, and serves PATCH endpoints as well as reads.

This page builds that shape at the smallest size that shows it: three modules and one customer. Each spec is an interface annotated @GenerateMapping, which the processor implements, much as MapStruct implements a @Mapper.

flowchart LR
    accTitle: The estate's three modules
    accDescr: The service module depends on the API module and on the clients module, and reads both as compiled classes. The API module holds the vocabulary and runs no annotation processor. The clients module holds the wire beans and runs Lombok. The service module holds the domain and the specs, and runs the mapping processor.
    S["estate-service<br/>the domain and three specs<br/>runs the mapping processor"]
    A["estate-api<br/>the shared vocabulary<br/>no processor"]
    C["estate-clients<br/>the wire beans<br/>runs Lombok"]
    S -->|"compiled classes"| A
    S -->|"compiled classes"| C
    classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
    classDef domain fill:#a6d189,stroke:#40a02b,color:#232634
    class C wire
    class A,S domain

In words: the service module depends on the other two, and it is the only one that runs the mapping processor.


The API module: a vocabulary, and no processor

The API module, which many estates call common, owns what every service shares: the EmailAddress value type, and the vocabulary that maps it. A vocabulary is a plain interface, not a spec, so nothing in this module is generated. Its build needs the library and no annotation processor:

plugins {
    `java-library` // for api(...)
}

dependencies {
    api(project(":hkj-core")) // the library, and no annotation processor
}

These builds use this repository's project paths, and In your own build gives yours.

public interface ContactVocabulary {
  @MapField(to = "fullName")
  String name();

  default ValidatedPrism<String, EmailAddress> email() {
    return EmailCodecs.EMAIL;
  }
}


public final class EmailCodecs {
  public static final ValidatedPrism<String, EmailAddress> EMAIL =
      ValidatedPrism.of(
          raw ->
              raw.contains("@")
                  ? Validated.validNel(new EmailAddress(raw))
                  : Validated.invalidNel(FieldError.of("not an email address")),
          EmailAddress::value);

  private EmailCodecs() {}
}

Every client in the estate calls a customer's name fullName, and every email parses through one leaf, so both live here once. The @MapField rename is kept in the compiled class, which is what lets a spec in another module read it.


The clients module: beans as a partner ships them

The clients module stands in for a partner's client jar, so its beans are written the way the tools write them. The customer resource has the shape a generator writes, with an id that arrives as a String:

public class CustomerResource {
  private @Nullable String id;
  private @Nullable String fullName;
  private @Nullable String email;
  private @Nullable String nickname;
  private @Nullable AddressBean address;

  // ...a getter and a setter for each, value equality, as a generator writes them

The address is a Lombok @Data bean:

@Data
public class AddressBean {
  private String street;
  private String city;
  private String postcode;
}

The PATCH request is a generator-shaped bean too, with every property null until a request sets it. Its nickname is the one hand-shaped property: an Optional, so that a client can clear it. openapi-generator writes a JsonNullable there by default, which is not supported yet.

public class CustomerPatch {
  private @Nullable String fullName;
  private @Nullable String email;
  private @Nullable Optional<String> nickname; // null: omitted; empty: "nickname": null
  private @Nullable AddressBean address;

  // ...a getter and a setter for each, as a generator writes them

Lombok runs in this module, and the mapping processor does not:

plugins {
    `java-library` // for api(...)
}

dependencies {
    compileOnly(libs.lombok) // org.projectlombok:lombok
    annotationProcessor(libs.lombok) // Lombok, and no mapping processor
    api(libs.jspecify) // org.jspecify:jspecify, for @Nullable
}

By the time the service module compiles, the address bean's getters and setters are ordinary compiled methods, as they would be in a partner's jar. The mapping processor reads them there as it reads any bean's, so the service module needs no Lombok. A Lombok bean in the same module as its spec maps too, as long as Lombok runs first: the generated-client checklist says how to order the two.


The service module: the specs

The service module holds the domain and the specs. It is the one module that runs the mapping processor:

dependencies {
    implementation(project(":hkj-examples:estate-api"))
    implementation(project(":hkj-examples:estate-clients"))
    implementation(project(":hkj-core"))
    annotationProcessor(project(":hkj-processor")) // the mapping processor runs here, and only here
}

The domain is the order service's customer at the size a service keeps: an id, a nickname and an address beside the name and the checked email. Like the first capstone's larger Order, it lives in modules of its own.

public record Customer(
    UUID id, String name, EmailAddress email, Optional<String> nickname, Address address) {}

public record Address(String street, String city, String postcode) {}

Three specs map it. AddressMapping maps the address to the Lombok bean component by component, with nothing to declare. The customer resource and its PATCH each extend the API module's vocabulary, and the resource adds a leaf for its UUID:

@GenerateMapping
public interface AddressMapping extends MappingSpec<Address, AddressBean> {}

@GenerateMapping
public interface CustomerResourceMapping
    extends ContactVocabulary, MappingSpec<Customer, CustomerResource> {
  default ValidatedPrism<String, UUID> id() {
    return StandardCodecs.uuid();
  }
}

@GenerateMapping
public interface CustomerPatchMapping
    extends ContactVocabulary, UpdateSpec<Customer, CustomerPatch> {}

No spec names a module. Both customer specs nest the address through AddressMapping, the only spec for that pair, as Nesting a spec describes. The domain's Optional nickname maps to the resource's nullable String with nothing declared: build writes null for an empty nickname, and parse reads a null as empty.


What the build proves

The processor writes an Impl for each spec. The test binds them once, keeps one stored customer, Ada, and binds a PATCH body with Jackson as a controller would:

  // The generated Impls, bound once for the class.
  private static final CustomerResourceMappingImpl RESOURCE = CustomerResourceMappingImpl.INSTANCE;
  private static final CustomerPatchMappingImpl PATCH = CustomerPatchMappingImpl.INSTANCE;

  private static final JsonMapper JSON = JsonMapper.builder().build();

  private static final Customer ADA =
      new Customer(
          UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
          "Ada Lovelace",
          new EmailAddress("ada@example.org"),
          Optional.of("Countess"),
          new Address("1 High Street", "Leeds", "LS1 4AP"));

  /** Binds a PATCH body as a controller would, then applies it to Ada as she is stored. */
  private static Validated<NonEmptyList<FieldError>, Customer> patch(String body) {
    return PATCH.updateFrom(JSON.readValue(body, CustomerPatch.class)).apply(ADA);
  }

The resource round-trips through all three modules:

    CustomerResource wire = RESOURCE.build(ADA);

    assertThat(wire.getFullName()).isEqualTo("Ada Lovelace"); // the api module's rename
    assertThat(wire.getNickname()).isEqualTo("Countess"); // an empty Optional would be null
    assertThat(wire.getAddress().getCity()).isEqualTo("Leeds"); // Lombok's compiled accessors
    assertThatValidated(RESOURCE.parse(wire)).hasValue(ADA);

A resource with three bad fields reports all three, and the one inside the Lombok bean is located by its path:

    CustomerResource wire = RESOURCE.build(ADA);
    wire.setId("NOPE");
    wire.setEmail("not-an-email");
    wire.getAddress().setCity(null);

    assertThatValidated(RESOURCE.parse(wire))
        .isInvalid()
        .hasFieldErrors(
            "id: not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000)",
            "email: not an email address", // the api module's leaf
            "address.city: must not be null"); // inside the Lombok bean

In a Spring service, these errors become the field list of the 422 response.

Bound from real JSON, the PATCH keeps what a request leaves out, clears the nickname on an explicit null, and replaces the address whole:

    assertThatValidated(patch("{}")).hasValue(ADA); // omitted: kept

    assertThatValidated(patch("{\"nickname\": null}")) // a JSON null: cleared
        .hasValue(new Customer(ADA.id(), ADA.name(), ADA.email(), Optional.empty(), ADA.address()));

    assertThatValidated(
            patch(
                "{\"address\": {\"street\": \"2 Park Row\", \"city\": \"York\","
                    + " \"postcode\": \"YO1 7HH\"}}")) // an address: replaced whole
        .hasValue(
            new Customer(
                ADA.id(),
                ADA.name(),
                ADA.email(),
                ADA.nickname(),
                new Address("2 Park Row", "York", "YO1 7HH")));

Checkpoint: half an address

A client wants to change only the street, and sends {"address": {"street": "2 Park Row"}}. What does the PATCH return?

  1. Ada, with the new street, and her city and postcode kept
  2. Ada, with an address of 2 Park Row and no city or postcode
  3. Invalid, with address.city: must not be null and address.postcode: must not be null
  4. Ada, unchanged: the incomplete address is ignored

Answer and why

3. A PATCH replaces a nested object whole rather than merging it, so the address parses through AddressMapping like any full address. Inside a sent object, a null no longer means leave unchanged: the city and postcode the client left out are null, and each is a located error.

    assertThatValidated(patch("{\"address\": {\"street\": \"2 Park Row\"}}"))
        .isInvalid()
        .hasFieldErrors("address.city: must not be null", "address.postcode: must not be null");

A client that means to change the street sends the whole address.

Where this lives: What each JSON state does.

Each customer mapping obeys its laws, in one call. A built resource parses back to the same customer, and a bad one is refused. An empty PATCH changes nothing, and applying one twice is the same as applying it once. The address mapping runs inside the resource's round trip:

    CustomerResource good = RESOURCE.build(ADA);
    CustomerResource bad = RESOURCE.build(ADA);
    bad.setEmail("nope");
    MappingLaws.assertMappingLaws(RESOURCE.asValidatedPrism(), good, bad);

    CustomerPatch rename = new CustomerPatch();
    rename.setFullName("Augusta Ada King");
    CustomerPatch badEmail = new CustomerPatch();
    badEmail.setEmail("nope");
    MappingLaws.assertMappingLaws(PATCH::updateFrom, ADA, new CustomerPatch(), rename, badEmail);

In your own build

This page wires the processor directly, as the repository's own build does. In your estate, apply the hkj Gradle plugin to each service module that declares specs, as the plugin page says. The API module and the client jars take the library as an ordinary dependency, or nothing at all. Multi-module builds covers the rest.


Key Takeaways

  • A vocabulary module needs no processor: a vocabulary is a plain interface, and its rename and leaves reach a spec in another module through its compiled classes
  • A client jar's beans map like any bean: the processor reads a Lombok or generated bean's accessors from the compiled class
  • An Optional property clears over PATCH: an explicit JSON null binds an empty Optional, and omitting the field keeps the value
  • A PATCH replaces a nested object whole: a sent address must be complete
  • The laws cover the estate too: one call for each customer mapping

See Also


Previous: Injecting, Testing, and Diagnostics Next: Mapper at a Glance