Production Readiness

What optics cost at run time and build time, and the conventions that keep a team consistent.

This page does not offer a marketing case for using optics in production; it offers honest answers to the questions a senior engineer asks before adopting a new abstraction in a codebase others must maintain.

Find your question, then follow its link:

You are askingGo to
Does a lens update allocate more than a hand-written with* cascade?What set and modify allocate
Does a traversal rebuild its container on every call, even to read it?What reads cost and Traversal allocation
Which collection optic copies the whole map, and which hands back the source unchanged?What each collection optic builds
What does a prism or affine cost when it does not match?Prisms and affines on a miss
Does validating every value cost more with Validated than with Either?modifyF and effect handlers
Does an adapter or a Focus path add work on every call?Adapters and Focus paths
Should I store a composed optic in a constant, and where?Caching optics and When to extract optics
What does the processor add to the build: time, incremental compilation, -Werror, coverage, Lombok beside it?Build-time impact
Will generated classes and the Focus DSL stay stable across versions?Versioning and stability
What should a team agree on before using optics widely?Team conventions that work

Runtime cost

In short, with the method that decides each named in its own subsection:

You callIt costs
A lens set or modifyWhat a hand-written with* cascade allocates
A traversal modifyA rebuilt container, on every call
A read through Traversals.getAll or asFold()No rebuild
A query such as find, exists or previewStops at the focus that answers it
An At editA copy of the whole map or list
A prism or affine modify that missesNothing: no allocation and no rebuild

What set and modify allocate

Every set or modify call on a Lens over a record allocates one new record per layer of nesting touched. A composed lens through three layers allocates three new records, plus any intermediate captures. There is no in-place mutation; that is the cost of immutability and not specific to optics. Everything off the path keeps its reference: a generated lens passes the record's other components to its constructor as they are.

Compared to a hand-written with* cascade for the same nested update, generated optics typically incur the same allocation count. The difference is the two or three anonymous Lens and FocusPath objects the composition allocates.

For a single update on a small record, the cost is unlikely to matter. For tight inner loops, see Caching optics.

These are engineering estimates from the shape of the generated code, not benchmark output. The JMH suite covers Fold.plus, and how a list traversal's time grows with the list, but not lens or traversal allocation.

modifyF and effect handlers

modifyF(f, source, applicative) runs f once per focused element and threads the results through the supplied Applicative. The cost is one call to f plus whatever the applicative's ap and pure do. Validated accumulates every error and Either keeps only the first, but neither skips work: the traversal applies f to every focused element before the applicative combines the results, so the choice shapes the answer rather than the cost.

Traversal allocation

Traversals.modify(traversal, f, source) over a List<A> allocates one new list, plus a small constant number of short-lived objects per element: the traversal threads each result through the Id applicative and an immutable cons-list before flattening. Budget O(n) allocations, not O(1). (Reads and writes on a bare Traversal go through the Traversals utility; the interface itself declares no plain read or write.) If the function returns the same value for every element (a no-op modify), the list is still rebuilt; optics do not compare references to skip rebuilding.

A Focus path's .each(EachInstances.listEach()) is Traversals.forList(), and EachInstances.arrayEach() is Traversals.forArray(), as EachInstances.listEach and EachInstances.arrayEach show. Both rebuild this way, in time linear in the length, into a new unmodifiable list or a new array.

What each collection optic builds

Each collection optic rebuilds its container in its own way, and several return the source itself when there is nothing to change. The third column says which, and the last names the method that decides it.

OpticWhat it buildsWhen nothing changesImplemented in
Traversals.forList(), a Focus path's .each(EachInstances.listEach()), EachInstances.arrayEach().each()Collects the results on an immutable cons list, then builds one new unmodifiable list, or one new array.An empty list or array still gets a new, empty one.Traversals.forList, Traversals.forArray
Traversals.forMapValues()Copies the keys and values into two lists, traverses the values, then builds one new LinkedHashMap in the source's order.An empty map still gets a new, empty map.Traversals.traverseMapValues
Traversals.forMap(key)Copies the whole map to replace one value.An absent key returns the source itself.Traversals.forMap
filtered(p), filterBy(query, p)Tests each element once, in the same pass as the update; filterBy's test is a Fold.exists, which stops at the first queried focus that matches.A rejected element passes through with of, keeping its reference.Traversal.filtered, Traversal.filterBy
ListTraversals.taking(n), slicing(from, to) and the other limitsRun the function on the slice only, then copy the elements outside it by reference into one new unmodifiable list; takingWhile and droppingWhile first scan for the split point.An empty slice returns the source itself, apart from dropping and droppingLast with n <= 0, which rebuild even an empty list.ListTraversals.slicing
StringTraversals.chars(), worded(), lined()Split the string into a list, one boxed Character per character or a regular-expression split into words or lines, then join one new string.A new string is joined anyway.StringTraversals.chars, StringTraversals.worded
Traversals.partsOf(t)get collects the foci into one list through getAll, and set walks the traversal again, so a modify walks it twice. sorted and reversed copy that list once more, and distinct twice.The traversal is still walked twice.Traversals.partsOf
IndexedTraversals.forList(), forMap()Pass the index and the value to your function as two arguments, so no Pair is built per focus.An empty list or map returns the source itself.IndexedTraversals.forList, IndexedTraversals.forMap
IndexedTraversals.toIndexedList, IndexedTraversals.length, asIndexedFold(), iandThentoIndexedList builds one Pair per focus to collect the foci, and iandThen one per focus because its index is a pair. length and asIndexedFold() build none: like asFold(), they run the traversal in a constant applicative, which skips every rebuild.Not applicable: these read.IndexedTraversals.toIndexedList, IndexedTraversal.asIndexedFold, IndexedTraversal.iandThen
Setter.forList(), Setter.forMapValues()modify maps straight into a new list or LinkedHashMap, with no applicative. modifyF collects the effects, sequences them from the right onto an immutable cons list, and builds the result once.The collection is rebuilt anyway.Setter.forList, ConsList.sequence
At: AtInstances.mapAt(), listAt()Every set, insertOrUpdate, remove and modify copies the whole map or list.Copies anyway, even when the index is absent.AtInstances.mapAt, AtInstances.listAt
Ixed: IxedInstances.mapIx(), listIx()An edit copies only when the index is present, and a read through IxedInstances.get copies nothing.An absent index returns the source itself.IxedInstances.fromAt

A run of At edits therefore copies the map once per edit. To make many edits at once, copy the map into a LinkedHashMap once, change that copy, and carry on with the result.

Prisms and affines on a miss

A generated prism tests its case with instanceof for a sealed subtype, == for an enum constant, or your predicate for @MatchWhen. It answers a miss with the JDK's shared Optional.empty(), so a miss allocates no Optional. On a miss, Prism.modify and Affine.modify return the source itself, so nothing is rebuilt. Prism.modifyF and Affine.modifyF wrap the source with of only on a miss, so a match allocates nothing for the branch it does not take. Affine.andThen chains the two reads with Optional.flatMap, or map for a lens step, so a composed affine's getOptional stops at the first absent step. Inside a traversal, Traversal.andThen(Prism) passes a non-matching element through with of, so the rest of the chain never runs for it.

What reads cost

A read through a traversal or a fold rebuilds nothing, and a query that looks for an answer stops at the focus that settles it.

ReadWhat it visits and buildsImplemented in
Traversals.getAll(t, s), t.asFold().getAll(s), and a TraversalPath's getAll(s)Run modifyF in a constant applicative, which skips every rebuild, though a traversal's own set-up, such as a map's key and value lists or a split string, still runs.Traversal.asFold, Traversals.getAll
A Fold's preview, find, exists, all and isEmptyStop the fold at the focus that settles the answer, so no later focus is visited and the predicate is not called on one. getAll and length visit every focus.Fold.find, FoldSearch.first
An IndexedFold's findWithIndex, existsWithIndex, allWithIndex, their value-only forms, and isEmptyStop at the answer, as a Fold's queries do. The forms that take an index, and find, build one Pair per focus they visit.IndexedFold.findWithIndex
A TraversalPath's preview, count, exists, all, find and isEmptyRead through the path's asFold(): preview, exists, all, find and isEmpty stop at their answer as a Fold's queries do, and count visits every focus. A path from traced reads them through getAll, so its observer sees every focus.TraversalPath.exists
IxedInstances.get, IxedInstances.containsRead through Traversals.getAll, so they copy nothing, and neither does At.get.IxedInstances.get
A fold from @GenerateFoldsLoops over an iterable component directly, or applies the function to a single one, with no intermediate list.FoldProcessor
getMaybe, previewMaybe, findMaybe, getAllMaybeMake the plain read, then convert it: a new Just for a value, the shared Nothing for none.GetterExtensions.getMaybe, FoldExtensions

Adapters and Focus paths

contramap, map and dimap each return one optic that runs your conversion functions around the wrapped optic's modifyF, as Optic.dimap shows, and add nothing else. An adapter therefore costs what your functions cost, on every call.

A Focus path holds the optic it was built from, and its reads and writes delegate to that optic. Each via composes the two optics with andThen into a new path. A via with another path also joins the two label lists, and a via with a bare optic keeps this path's list, as FocusPath.via shows. That is a building cost, which Caching optics removes.


Caching optics

A lens or focus path is a value, not a function. Building the path has a one-off allocation cost, which caching removes; applying it still allocates the rebuilt structure, which nothing removes. For paths used repeatedly, store them as static final:

private static final Lens<Company, String> COMPANY_NAME =
    CompanyLenses.name();

private static final TraversalPath<Order, BigDecimal> ALL_PRICES =
    OrderFocus.lines().price();

This matters most for paths constructed by andThen chains, where the whole composition is rebuilt on every call. The saving is smaller but real for a single accessor too, because a generated accessor is a factory rather than a constant: CompanyLenses.name() calls Lens.of(...) and allocates a fresh Lens every time, and CompanyFocus.name() allocates a Lens and a FocusPath.

Both cases assume the path is used more than once. A path used once has no allocation to amortise, which is why When to extract optics still says to inline it at the call site.


Build-time impact

The annotation processor adds one code-generation pass to compilation. On a codebase with around a hundred annotated records the additional time is in the low single digits of seconds; large codebases scale roughly linearly with the number of annotated types.

Generated sources land under build/generated/sources/annotationProcessor/java/main (Gradle) or target/generated-sources/annotations (Maven). Most IDEs index these automatically after the first build. If autocomplete cannot see XLenses or XFocus types, a rebuild and project refresh resolves it.

Incremental compilation is supported, conservatively: every processor that generates code is registered with Gradle as aggregating, so a change to any annotated type re-runs generation across the source set rather than regenerating one companion class. That is deliberate, an isolating claim that turned out wrong would produce silently stale output, and consuming source sets stay incremental regardless. The one processor that generates nothing is registered as isolating: it marks hkj's annotations as handled, so that javac's -Xlint:processing does not report them (see below).

Every class file the processors write carries @Generated: the companion class, the navigator and stage classes nested inside it, and the classes that implement each traversal and fold. The marker has class retention, which is what JaCoCo's generated-code filter reads, so a coverage floor needs no exclude patterns for XLenses, XFocus, XTraversals or anything nested in them, and a build that carried such patterns can drop them. The same marker is what the compile-time checks gate on.

Generated code is written to compile under -Xlint:all -Werror. javac reports every annotation that no processor claims, that is, declares it handles, so hkj's processors claim @Generated and every annotation they read alongside a generating one, such as @MapField or @Wither. Two warnings are outside hkj's reach: an annotation from another library that no processor claims, such as jspecify's @NullMarked, which @EffectAlgebra and @ComposeEffects write onto the classes they generate, or Spring's, which an @HkjHttpClient client carries; and, in a named module, the exports lint on a public signature that names a type from a module required without transitive. A build that keeps -Werror beside such annotations turns that one lint off with -Xlint:all,-processing; for the exports warning, declare the requirement transitive.

A type-use annotation you write on a component, a bound or a type argument is copied onto the types generated code writes out, so a @Nullable still reads as nullable in a lens's focus or a mapping Impl's leaf. An annotation the generated file cannot write cleanly is left off instead of breaking the build: one missing from the compile classpath, one declared private or inside a private class, one package-private to a package other than the one the file is written into, and one that is itself deprecated, which would draw a warning in a file you cannot put a @SuppressWarnings on. Inside a @NullMarked scope an unannotated type means non-null, so an annotation left off that way changes what the generated signature says: keep the nullness annotations generated code has to repeat public and current.

javac offers each round's annotations to the processors in processor-path order, and stops once every annotation in the round is claimed. A processor that supports every annotation, such as Lombok's, belongs ahead of hkj-processor on the processor path, or a round whose annotations are all hkj's never reaches it. A build that names its processors, with -processor or Maven's <annotationProcessors>, adds org.higherkindedj.optics.processing.CompanionAnnotationProcessor to the list; the HKJ Maven plugin adds it at its own release.


When to extract optics

SituationRecommendation
Path used once in a method bodyInline at call site (ConsignmentFocus.to().city().get(consignment))
Path used multiple times in the same classExtract to a private static final field
Path used across packagesExtract to a public static final field on a domain-optics utility class
Path constructed dynamically from runtime inputBuild inside the method; do not cache
Path inside a tight loopExtract to a local variable above the loop

The optic value's type carries useful documentation. A Lens<Company, String> field named companyName reads more cleanly than a method that recomputes the path.


Versioning and stability

The annotation surface (@GenerateLenses, @GenerateFocus, @GeneratePrisms, @GenerateTraversals, @GenerateFolds, @GenerateGetters, @GenerateSetters, @GenerateIsos, @ImportOptics, OpticsSpec) is the stable contract you depend on. Changes to method names on these annotations follow semantic-versioning expectations.

The shapes of generated classes (XLenses, XFocus, etc.) are also stable; existing fields and methods do not disappear without a deprecation cycle. New fields and methods may be added to support new annotation parameters; this is additive and source-compatible.

The Focus DSL surface (FocusPath, AffinePath, TraversalPath, methods like .each(...), .via(...), .modifyAll(...)) is stable. The Free Monad DSL APIs (OpticPrograms, OpticInterpreters) are also stable but used by fewer projects; if you adopt them, weigh the smaller adoption surface accordingly.

Profunctor adaptations (contramap, map, dimap) are stable.

When upgrading across minor versions, regenerate by rebuilding. Generated code is compatible with the runtime library version that produced it; mixing differently-versioned generated code and runtime jar can produce subtle runtime errors and is not supported.


Team conventions that work

These are the conventions the library's own examples and tests follow. Treat them as defaults, not mandates.

  • Annotate the records you own as you write them. Adding @GenerateLenses later is mechanical, but discovering mid-task that the optic does not exist is not. Weigh it against the build-time note above: generation scales with the number of annotated types, so "every record in the monorepo" is a different proposition from "every record in this module".
  • Place optic constants near the domain type. A static Optics utility class next to the record carries the well-known paths.
  • Name paths after the field they end at. companyName, not companyToName or getCompanyName. The receiver-style naming reads naturally at call sites: Optics.companyName.set("...", company).
  • Use Fold when you only read. Even when a Lens would work, expressing read-only intent makes reviews easier and prevents accidental mutations.
  • Reach for the Focus DSL first. Manual andThen composition is fine and sometimes clearer, but the DSL gives you better IDE support and shorter call sites for nested updates.
  • Reserve the Free Monad DSL for problems that demand it. If you do not have an audit, structural-analysis, or multi-mode requirement, the everyday APIs are simpler.

See Also


Previous: Look It Up Next: Annotations at a Glance