v0.4.6 (7 June 2026)

v0.4.6 on GitHub

Optic-Driven Request Batching

This release adds the user-facing surface around the org.higherkindedj.optics.fetch substrate so an optic traversal of N foci collapses to one batched backend call by swapping the strategy passed to Optic.modifyF. The optic core is untouched; the package is now exported and documented, the heterogeneous Id -> Entity case has a dedicated helper, and run-time failures land on the value channel as Either rather than as exceptions.

  • Optic-Driven Batching: New chapter under Optics / Integration and Recipes, with three diagrams (N+1 vs batched, the optic + applicative + runner pipeline, the applicative-versus-flatMap round cost) and a language-agnostic primer link for the DataLoader/Haxl idea. org.higherkindedj.optics.fetch is now exported from the core module; Done/Blocked/PendingKeys remain package-private so consumers interact with Fetch only through its static factories and runners
  • SafeFetch: Total runner that captures resolver exceptions, missing-key reports, loader failures, and deadlines as Either.left values on the value channel instead of thrown exceptions; SafeFetch.runCached, runAsync, and runAsyncWithTimeout never throw and the safe-async future never completes exceptionally. SafeFetch.partition splits a per-key Either<E, V> result list into aligned successes and failures so a partial-success batch is preserved end-to-end
  • SourceRouter.routed: Composes per-source BatchLoaders with a classifier into one loader the substrate can call; one round fans out to one concurrent dispatch per source, so a list mixing user ids and product skus produces exactly one call per backend, not one call per key
  • BatchLoaders.chunked(loader, maxSize): Caps a single dispatch's size for backends that enforce a per-request limit ($in clause cap, HTTP query-string ceiling, GraphQL batch limit); the substrate still sees one round and the loader splits the keyset behind the curtain
  • FetchOptics.fetchEach(source, rebuild): The type-changing list-traversal the codegen does not produce (codegen optics are type-preserving, so a Traversal<Team, UserId> cannot directly describe loading each UserId into a User); builds an Optic<S, T, A, B> from a list-reader and a rebuild function so heterogeneous fetch composes with the rest of the optic graph
  • Tutorial 21: New tutorial journey (exercise + teaching-solution) covering the four pieces (same-type batching, heterogeneous fetch, multi-source routing, railway errors) with five exercises and tiered hints; the solution carries the Why this is idiomatic / Alternative / Common wrong attempt commentary per exercise
  • Architecture-rule update: The optics.fetch package is exempted from the "no specific HKT type dependencies" rule alongside the existing optics.util, optics.extensions, and optics.fluent exemptions, on the same basis: SafeFetch's railway runner is built around Either by design

Plan Introspection and Guardrails for Optic Batching

The audit and safety-rail layer on top of Optic-Driven Batching: a way to fold a Fetch program into a structural plan without I/O, and a per-round guard that interposes between the program and its resolver to refuse runaway batches before they leave the JVM. Both compose with SafeFetch so refusal is a value, not a thrown exception.

  • Plan Introspection and Guardrails: New chapter under Optics / Integration and Recipes covering the offline Plans.preflight walk, the per-round Guard family, and the railway-safe refusal pattern.
  • Plans.preflight: Folds a Fetch program into a Plan<K> with zero I/O. Each round's keyset is recorded in dispatch order; round 1 is universally observable, and later rounds are walked on stub values when the program's combine logic tolerates null (a Plan.truncated() flag is the honest signal otherwise).
  • Guards.maxKeysPerRound / maxRounds / maxBackendCalls / audit / none: Standard guards that pass or refuse a round at the runner boundary; compose with Guard::and. A refusal aborts the run with GuardViolationException carrying the offending roundIndex and pendingKeys.
  • Guards.runCached / runAsync: Drop-in replacements for the substrate runners with the guard interposed; the resolver is never called for a refused round.
  • SafeFetch.runCachedWithGuard / runAsyncWithGuard: Railway variants that capture a refusal as Either.left(GuardViolationException); the run never throws and the safe-async future never completes exceptionally.
  • Tutorial 22 (exercise + teaching solution) covering the five pieces (preflight, truncation, refusal, audit, railway capture).

Test-suite consolidation (internal)

Mostly an internal refactor of the hkj-core test suite The one user-visible piece is in hkj-test: new reusable law helpers (FunctorLaws, ApplicativeLaws, MonadLaws, SelectiveLaws) and a KindEquivalence.byEqualsAfter helper that downstream users can call to verify their own type-class instances. The Kind-accepting overloads on EitherAssert / MaybeAssert / TryAssert / IOAssert / LazyAssert / ReaderAssert / ValidatedAssert / WriterAssert / VStreamAssert / VTaskAssert now match the auto-narrowing pattern of ListAssert / OptionalKindAssert / StreamAssert / IdAssert.

N-ary Coupled Lenses

The arity ladder above Lens.paired: a record with three or more cross-field invariants can now be updated atomically through a single coupled3..coupled9 call instead of nested paired workarounds.

  • Coupled Fields chapter: "Three or More Coupled Fields" section rewritten to show the new ladder; the old "nest pairs / feature request" guidance is replaced.
  • CoupledLenses.coupled3 ... coupled9 (in org.higherkindedj.optics.util): seven static factories, each with two overloads mirroring Lens.paired exactly (preserving form taking (S, A, B, ...) -> S; simple form taking the constructor reference (A, B, ...) -> S). Returns Lens<S, TupleN<...>> reconstructed atomically.
  • hkj-processor adds CoupledLensGenerator, wired into the existing @GenerateForComprehensions trigger alongside the Tuple/For-step generators. Generation caps at arity 9 (cross-field invariants past that point are vanishing in practice); raising the cap is a one-line change to the generator.
  • Tutorial 23 (exercise + teaching solution) demonstrating coupled3 on a 3-field monotonic invariant and coupled5 on the same shape at higher arity, including the canonical "chained set throws" failure mode that coupled lenses sidestep.

API Deprecations Ahead of 0.5.0

This release prepares users for a record-shape change to StateT that lands in 0.5.0. The single-argument runner methods and the explicit monadF() accessor are deprecated now so call sites can migrate ahead of time; the record component itself stays in place until 0.5.0.

  • StateT runner methods accept an explicit Monad<F>: new overloads StateT.evalStateT(state, monad) and StateT.execStateT(state, monad) use the supplied monad rather than the one stored on the record. The matching helpers StateTKindHelper.evalStateT(kind, state, monad) and execStateT(kind, state, monad) are added on the same shape. New code should prefer these overloads; the single-argument forms are deprecated for removal in 0.5.0 (#445)
  • StateT.monadF() deprecation: the explicit accessor is now @Deprecated(forRemoval = true). In 0.5.0 the monadF record component itself is removed so that two StateT values with the same state function are considered equal regardless of which Monad instance they were constructed with. equals, hashCode, and toString therefore change in 0.5.0; until then the record-generated implementations still discriminate on the stored monad, which is the underlying defect the deprecation is staging
  • Internal call sites in MutableContext.evalWith and execWith are migrated to the new two-argument overloads; library builds emit no deprecation warnings of their own

Try.fold and TryPath.fold argument-order rename ahead of 0.5.0

Try.fold(successMapper, failureMapper) and TryPath.fold(successMapper, failureMapper) are the two surfaces whose fold is success-first, against the error-first convention used by Either.fold, Validated.fold, EitherF.fold, EitherPath.fold, and ValidationPath.fold. A naked argument swap on fold would silently invert behaviour for the common case of parameter-ignoring lambdas (v -> v, v -> null, Throwable::getMessage) and for Object-typed method references, because both Function parameters have different generic bounds but lambdas often resolve to either. The fix is published under a distinct name so the change becomes a compile error rather than a runtime inversion.

  • Try.foldFailureFirst(failureMapper, successMapper) and TryPath.foldFailureFirst(failureMapper, successMapper) are the canonical error-first replacements, added since 0.4.6. Their argument order matches Either.fold / Validated.fold / EitherF.fold / EitherPath.fold / ValidationPath.fold. The methods are named foldFailureFirst rather than overloading match (which would create a lambda-inference ambiguity with the existing Try.match(Consumer, Consumer)) or fold (which would silently invert behaviour for downstream call sites).
  • Try.fold(successMapper, failureMapper) and TryPath.fold(successMapper, failureMapper) are now @Deprecated(forRemoval = true) and are removed in 0.5.0. Internal call sites across hkj-core (TryApplicative, TryTraverse, TryPath, PathOps, VTaskContext, LensExtensions, Affines), hkj-test (VTaskPathAssert, VTaskContextAssert), hkj-spring return-value handlers, hkj-processor-plugins (the TryGenerator annotation-processor template), and every runnable example, tutorial, and solution are migrated to foldFailureFirst; library builds emit no deprecation warnings of their own.
  • The canonical name fold is planned to be reintroduced on both Try and TryPath with the error-first argument order in 0.6.0, once 0.5.0 has removed the success-first fold and every reachable call site has been forced through the renamed method. See #452 for the design rationale and the follow-up issue tracking the 0.6.0 reintroduction.
  • OpenRewrite migration: a new SwapTryFoldToFoldFailureFirstRecipe is added to the existing MigrateDeprecationsTo0_5_0 recipe group in hkj-openrewrite. The recipe matches Try.fold(successMapper, failureMapper) and TryPath.fold(successMapper, failureMapper) call sites and rewrites them to foldFailureFirst(failureMapper, successMapper), atomically renaming the method and swapping the two arguments. It cannot be expressed as a stock ChangeMethodName invocation because the argument order changes; a naked rename would silently invert behaviour. Downstream consumers run org.higherkindedj.openrewrite.MigrateDeprecationsTo0_5_0 to migrate Try.fold, TryPath.fold, StateTKind.narrowK, and KindValidator.narrowWithPattern in one pass.

Previous: v0.4.7 Next: v0.4.5