Focus DSL with External Libraries

Navigate from your records into Immutables, Lombok, AutoValue or Protobuf types with one Focus path.

"The best interface is no interface at all. The second best is the one that feels invisible."

– Don Norman

What You'll Learn

  • Join a Focus path to an optic generated for a type you do not own with .via()
  • Write a spec interface with @Wither for an Immutables value, or @ViaBuilder for Lombok, AutoValue and Protocol Buffers
  • Decide when to convert with toLens(): only when you want a reusable Lens constant rather than a path
  • Organise the bridges in one layer named for the domain, and law-check each with LensLaws

See Example Code

CompanyBridge.java | AddressOpticsSpec.java

The page includes these directly, so the build compiles every bridge shown here.


The best solution would be Focus DSL working seamlessly with every external type, no extra code. That is not possible; external libraries do not know about our optics. The second best is what this page builds: a bridge that feels invisible in use, so a developer navigates from OfficeNetworkFocus.headquarters() into AddressOptics.city() without thinking about the boundary.

Here is the whole pattern, compiled by the build. A Focus path over your own records, joined by .via() to an optic generated for a type you do not own:

  public static TraversalPath<OfficeNetwork, String> allCompanyEmails() {
    // offices() returns TraversalPath<OfficeNetwork, Office>
    // via(TraversalPath) composes to TraversalPath<OfficeNetwork, OfficeWorker>
    return OfficeNetworkFocus.offices()
        .via(OfficeFocus.staff())
        .via(OfficeWorkerFocus.contact())
        .via(ContactDetailsOptics.email());
  }

OfficeNetworkFocus.offices() and OfficeFocus.staff() are generated from your records; ContactDetailsOptics.email() is generated from a spec interface for an Immutables value. .via() does not care which is which.


The Bridge Pattern

Focus covers the types you own. Spec interfaces cover the types you do not. The bridge layer is a handful of composed constants where the two meet, and the only real decision is which copy strategy the external library's generated code calls for. Database Records with JOOQ sets those four out in full; this page is about what happens once the external optics exist.


A Complete Example: Immutables Value Objects

The External Types

Address and ContactDetails come from an Immutables-generated module: accessor methods with no get prefix, and wither methods for modification.

// An Immutables module's Address, with the chapter's Address components
@Value.Immutable
public interface Address {
  String street();
  String city();
  String postcode();

  // Immutables generates withStreet(...), withCity(...), and a builder
}

The Spec Interface

@Wither names each getter and wither pair, and the processor generates AddressOptics:

@ImportOptics
public interface AddressOpticsSpec extends OpticsSpec<Address> {

  @Wither(value = "withStreet", getter = "street")
  Lens<Address, String> street();

  @Wither(value = "withCity", getter = "city")
  Lens<Address, String> city();

  @Wither(value = "withPostcode", getter = "postcode")
  Lens<Address, String> postcode();
}

Your Local Domain

Ordinary records, annotated as usual, with fields whose types come from the external library:

@GenerateFocus
@GenerateLenses
public record OfficeNetwork(String name, Address headquarters, List<Office> offices) {}

@GenerateFocus
@GenerateLenses
public record Office(String name, OfficeWorker manager, List<OfficeWorker> staff, Address location) {}

@GenerateFocus
@GenerateLenses
public record OfficeWorker(String id, String name, ContactDetails contact, BigDecimal salary) {}

The Bridge

.via() covers the composition itself. toLens() earns its place when you want the result to be a reusable Lens constant rather than a path, which is what lets code that has never heard of the Focus DSL consume it:

  public static final Lens<OfficeNetwork, String> HEADQUARTERS_CITY =
      OfficeNetworkFocus.headquarters().toLens().andThen(AddressOptics.city());

Using them looks like nothing in particular, which is the point. The values shown are for a sample Acme Corp, the one FocusBridgingExample runs against. Its headquarters is in New York and its two offices sit in Boston and Chicago, and a test holds each value:

    String city = CompanyBridge.HEADQUARTERS_CITY.get(acme);
    // "New York": read straight through the Immutables Address

    OfficeNetwork moved = CompanyBridge.HEADQUARTERS_CITY.set("Seattle", acme);
    // headquarters.city is "Seattle"; every other Address field, and acme itself, is untouched

    List<String> emails = CompanyBridge.allCompanyEmails().getAll(acme);
    // [alice@acme.com, bob@acme.com, carol@acme.com]: three records deep, across the boundary

    List<String> phones = CompanyBridge.allCompanyPhones().getAll(acme);
    // [617-555-0101, 617-555-0102, 312-555-0201]

Why this matters

Nothing in that chain knows it crosses a library boundary. OfficeFocus.staff() is generated from your record, ContactDetailsOptics.email() from an Immutables interface, and .via() treats them identically because both are optics. The boundary that usually shows up as a helper method per field, or a stream pipeline with a manual rebuild, disappears into one composition.

.via() needs no conversion

.via() has overloads for Lens, Prism, Affine, Traversal, Iso and for other Focus paths, so a generated external optic composes onto a Focus path as it stands. Reach for toLens(), toAffine() or toTraversal() only when the value you want to keep is a raw optic.


Using the Bridges

The service layer sees one vocabulary:

public final class CompanyService {

  /** Relocate the headquarters. */
  public OfficeNetwork relocate(OfficeNetwork company, String newCity) {
    return CompanyBridge.HEADQUARTERS_CITY.set(newCity, company);
  }

  /** Every employee email, company-wide. */
  public List<String> allEmails(OfficeNetwork company) {
    return CompanyBridge.allCompanyEmails().getAll(company);
  }

  /** Standardise phone numbers across the whole company. */
  public OfficeNetwork standardisePhones(OfficeNetwork company, UnaryOperator<String> formatter) {
    return CompanyBridge.allCompanyPhones().modifyAll(formatter, company);
  }

  /** Raise salaries in one city only. */
  public OfficeNetwork raiseIn(OfficeNetwork company, String city, BigDecimal multiplier) {
    return OfficeNetworkFocus.offices()
        .filter(o -> CompanyBridge.officeCity().get(o).equals(city))
        .via(OfficeFocus.staff())
        .via(OfficeWorkerFocus.salary())
        .modifyAll(salary -> salary.multiply(multiplier), company);
  }
}

The last method is worth a second look: filter narrows the traversal by a predicate that itself reads through a bridge, and the rest of the chain carries on unchanged.


Other Libraries, Same Shape

Each library differs only in which copy strategy its generated code exposes.

Lombok @Builder(toBuilder = true):

@ImportOptics
interface LombokPersonOpticsSpec extends OpticsSpec<LombokPerson> {

  @ViaBuilder(getter = "getName", setter = "name")
  Lens<LombokPerson, String> name();
}

AutoValue:

@ImportOptics
interface AutoPersonOpticsSpec extends OpticsSpec<AutoPerson> {

  @ViaBuilder(getter = "name", toBuilder = "toBuilder", setter = "setName")
  Lens<AutoPerson, String> name();
}

Protocol Buffers, over the CustomerMessage that protoc generates for Bean-Shaped Wires:

@ImportOptics
interface CustomerMessageOpticsSpec extends OpticsSpec<CustomerMessage> {

  @ViaBuilder(getter = "getName", setter = "setName")
  Lens<CustomerMessage, String> name();
}

A repeated field is set whole by protoc's addAllSkus, and the generated lens clears the field first, as Non-Standard Naming explains. Over DispatchRequest's skus:

@ImportOptics
interface DispatchRequestOpticsSpec extends OpticsSpec<DispatchRequest> {

  @ViaBuilder(getter = "getSkusList", setter = "addAllSkus")
  Lens<DispatchRequest, List<String>> skus();
}

See Database Records with JOOQ for the strategies in full.


Organising the Bridge Layer

Three habits keep the layer honest:

Name the bridges for the domain, not the path. employeeEmail() says what it reaches; contact().andThen(email()) says how, which is the part that should be free to change.

Document the boundary in one place. A short class comment naming the external library and its copy mechanism saves the next reader a trip through the generated sources.

Test the round trip. The external type is the part you do not control, so a lens-law check at the boundary is worth more than it is anywhere else:

@Test
void headquartersCityBridgeIsLawful() {
  LensLaws.assertLensLaws(CompanyBridge.HEADQUARTERS_CITY, acme, "Boston", "Seattle");
}

Key Takeaways

  • The bridge is one composition, not a framework. A Focus path plus a generated external optic, joined by .via() or toLens().andThen(...).
  • .via() already accepts raw optics. Convert with toLens() when you want a reusable Lens constant rather than a path.
  • A List field's generated path is already element-level, so it composes with the external optic directly.
  • The library only changes the copy strategy. Immutables takes @Wither, Lombok, AutoValue and Protobuf take @ViaBuilder, and everything downstream is identical.
  • Law-check at the boundary. The external type is the half you do not own; LensLaws is cheap insurance against a surprising withX.

See Also

Further Reading


Previous: Database Records with JOOQ Next: Validation, Batching and Auditing