Profunctor Optics: Recipes
Wrapper adapters, migration recipes, and a complete worked example
- How to build reusable wrapper-type lenses for strongly-typed value classes (
UserId,Email, etc.) - Migration patterns: exposing an old schema's view of new data, and when an
Isobridge is honest - Where the runnable example demonstrates the adaptation styles end to end
- How adapted optics interact with effectful updates and deep composition
This page is a recipe shelf for the production-shaped problems that optic adaptation solves. The conceptual material, including the crucial distinction between the raw Optic-level operations and the typed routes (composition, Iso, Lens.of), lives in Profunctor Optics; use this page when you need a copy-paste recipe.
Working with Type-Safe Wrappers
The Challenge: You want to use string manipulation functions on wrapper types:
// Strongly-typed wrappers
public record UserId(String value) {}
public record UserName(String value) {}
public record Email(String value) {}
@GenerateLenses
public record User(UserId id, UserName name, Email email, LocalDate createdAt) {}
The Solution: A single-field wrapper and its raw value are the textbook lossless pair, so give each wrapper an Iso and compose. Lens >>> Iso = Lens, so the full API survives:
public class WrapperAdapters {
// One Iso per wrapper: get unwraps, reverseGet rewraps
public static final Iso<UserId, String> USER_ID_VALUE = Iso.of(UserId::value, UserId::new);
public static final Iso<UserName, String> USER_NAME_VALUE = Iso.of(UserName::value, UserName::new);
public static final Iso<Email, String> EMAIL_VALUE = Iso.of(Email::value, Email::new);
// Composed lenses for User operations
public static final Lens<User, String> USER_NAME_STRING =
UserLenses.name().andThen(USER_NAME_VALUE);
public static final Lens<User, String> USER_EMAIL_STRING =
UserLenses.email().andThen(EMAIL_VALUE);
// Usage examples
public User normaliseUser(User user) {
return USER_NAME_STRING.modify(name -> {
String trimmed = name.trim();
if (trimmed.isEmpty()) {
return name; // split("\\s+") on "" yields one empty token, and charAt(0) would throw
}
return Arrays.stream(trimmed.toLowerCase().split("\\s+"))
.map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1))
.collect(joining(" "));
}, user);
}
public User updateEmailDomain(User user, String newDomain) {
return USER_EMAIL_STRING.modify(email -> {
String localPart = email.substring(0, email.indexOf('@'));
return localPart + "@" + newDomain;
}, user);
}
}
The wrapper Isos are lawful (wrap-then-unwrap is the identity in both directions), so the law harness in hkj-test can hold them to it.
Migration Patterns
Schema migrations are where adaptation earns its keep, but honesty about information loss decides the tool.
Exposing the Old Schema's View of New Data
Version bridges are usually lossy (V1's age cannot reproduce V2's birthDate), so they are not Isos. The honest recipe is to build the V1-shaped view directly on V2 with Lens.of:
public record PersonV1(String name, int age) {}
@GenerateLenses
public record PersonV2(String firstName, String lastName, LocalDate birthDate) {}
public class MigrationAdapters {
// A V1-shaped "name" view over V2 data: reads join, writes split
public static final Lens<PersonV2, String> V2_FULL_NAME =
Lens.of(
v2 -> v2.lastName().isEmpty()
? v2.firstName() // no trailing separator, so set-then-get round-trips
: v2.firstName() + " " + v2.lastName(),
(v2, name) -> {
String[] parts = name.split(" ", 2);
return new PersonV2(
parts[0],
parts.length > 1 ? parts[1] : "",
v2.birthDate());
});
}
Code written against "a person's name" keeps working during the migration, and nothing pretends the round trip through V1 preserves the birth date.
Database Schema Evolution
The same recipe scales to entity migrations. The V1-shaped views live in one adapter class, so when the migration completes you delete one file:
// Old database entity
public record CustomerEntityV1(Long id, String name, String email) {}
// New database entity
@GenerateLenses
public record CustomerEntityV2(
Long id, String firstName, String lastName, String emailAddress, boolean active) {}
public class SchemaAdapters {
public static final Lens<CustomerEntityV2, String> FULL_NAME =
Lens.of(
v2 -> v2.firstName() + " " + v2.lastName(),
(v2, name) -> {
String[] parts = name.split(" ", 2);
return new CustomerEntityV2(
v2.id(),
parts[0],
parts.length > 1 ? parts[1] : "",
v2.emailAddress(),
v2.active());
});
public static final Lens<CustomerEntityV2, String> EMAIL =
Lens.of(
CustomerEntityV2::emailAddress,
(v2, email) -> new CustomerEntityV2(
v2.id(), v2.firstName(), v2.lastName(), email, v2.active()));
}
If the two versions hold exactly the same information in different shapes, write the conversion pair as an Iso and compose old optics through it: Iso >>> Lens = Lens. Reach for the raw Optic.dimap bridge only when the pipeline is effectful and lives in modifyF anyway; see Profunctor Optics.
Complete, Runnable Example
OpticProfunctorExample works each adaptation style end to end (hand-building with Lens.of, rather than the raw Optic operations), and it compiles and runs on every build:
- The nested-source ("contramap") wish: a
Personfirst-name lens reaching throughEmployee, hand-built withLens.of - Map-style: a
LocalDatelens exposed as a formatted-string lens (read formats, write parses) - Dimap-style: a hobbies traversal driven across the
PersonDtowire shape throughmodifyF, with the conversion pair supplied at the boundary - Wrapper integration: a hand-built value lens reaching through a
UserNamewrapper - API adapter: the full internal-model-to-DTO round trip
Run it from the repository to see each scenario's printed before/after output.
Integration with Existing Optics
Adapted optics integrate exactly like the optics they wrap:
- Typed adapters (composition,
Iso,Lens.of) are ordinary lenses and traversals: they compose withandThen, convert withasTraversal()/asFold(), and accept effects throughmodifyF, including accumulating validation withInstances.validated(...). - Raw
Opticbridges (contramap/map/dimap) compose with other optics viaOptic.andThenand run throughmodifyF. They do not offerget/setdirectly, which is precisely why the typed routes are preferred outside effectful pipelines.
This adaptability is what lets you keep well-tested optics through data-format changes without rewriting core business logic.
- Wrappers want Isos:
Iso.of(Wrapper::value, Wrapper::new)composed after a lens keeps the whole API, lawfully - Lossy version bridges want
Lens.of: expose the old schema's view of the new data, and never call a lossy pair an Iso - Keep adapters in one place: a migration adapter class is deleted in one commit when the migration lands
- Effectful pipelines can use raw
dimap: anOpticbridge plusmodifyFreuses domain logic across wire shapes
- Profunctor Optics: the concepts and the typed-versus-
Opticdistinction these recipes apply - Isomorphisms: the lawful lossless conversions behind the wrapper recipe
- Validated Prisms: the right home for conversions that can fail
Previous: Profunctor Optics Next: Java-Friendly APIs