v0.4.6 (7 June 2026)
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-
flatMapround cost) and a language-agnostic primer link for the DataLoader/Haxl idea.org.higherkindedj.optics.fetchis now exported from the core module;Done/Blocked/PendingKeysremain package-private so consumers interact withFetchonly through its static factories and runners SafeFetch: Total runner that captures resolver exceptions, missing-key reports, loader failures, and deadlines asEither.leftvalues on the value channel instead of thrown exceptions;SafeFetch.runCached,runAsync, andrunAsyncWithTimeoutnever throw and the safe-async future never completes exceptionally.SafeFetch.partitionsplits a per-keyEither<E, V>result list into aligned successes and failures so a partial-success batch is preserved end-to-endSourceRouter.routed: Composes per-sourceBatchLoaders 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 keyBatchLoaders.chunked(loader, maxSize): Caps a single dispatch's size for backends that enforce a per-request limit ($inclause cap, HTTP query-string ceiling, GraphQL batch limit); the substrate still sees one round and the loader splits the keyset behind the curtainFetchOptics.fetchEach(source, rebuild): The type-changing list-traversal the codegen does not produce (codegen optics are type-preserving, so aTraversal<Team, UserId>cannot directly describe loading eachUserIdinto aUser); builds anOptic<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.fetchpackage is exempted from the "no specific HKT type dependencies" rule alongside the existingoptics.util,optics.extensions, andoptics.fluentexemptions, on the same basis:SafeFetch's railway runner is built aroundEitherby 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.preflightwalk, the per-roundGuardfamily, and the railway-safe refusal pattern. Plans.preflight: Folds aFetchprogram into aPlan<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 toleratesnull(aPlan.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 withGuard::and. A refusal aborts the run withGuardViolationExceptioncarrying the offendingroundIndexandpendingKeys.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 asEither.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(inorg.higherkindedj.optics.util): seven static factories, each with two overloads mirroringLens.pairedexactly (preserving form taking(S, A, B, ...) -> S; simple form taking the constructor reference(A, B, ...) -> S). ReturnsLens<S, TupleN<...>>reconstructed atomically.hkj-processoraddsCoupledLensGenerator, wired into the existing@GenerateForComprehensionstrigger 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
coupled3on a 3-field monotonic invariant andcoupled5on 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.
StateTrunner methods accept an explicitMonad<F>: new overloadsStateT.evalStateT(state, monad)andStateT.execStateT(state, monad)use the supplied monad rather than the one stored on the record. The matching helpersStateTKindHelper.evalStateT(kind, state, monad)andexecStateT(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 themonadFrecord component itself is removed so that twoStateTvalues with the same state function are considered equal regardless of whichMonadinstance they were constructed with.equals,hashCode, andtoStringtherefore 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.evalWithandexecWithare 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)andTryPath.foldFailureFirst(failureMapper, successMapper)are the canonical error-first replacements, added since 0.4.6. Their argument order matchesEither.fold/Validated.fold/EitherF.fold/EitherPath.fold/ValidationPath.fold. The methods are namedfoldFailureFirstrather than overloadingmatch(which would create a lambda-inference ambiguity with the existingTry.match(Consumer, Consumer)) orfold(which would silently invert behaviour for downstream call sites).Try.fold(successMapper, failureMapper)andTryPath.fold(successMapper, failureMapper)are now@Deprecated(forRemoval = true)and are removed in 0.5.0. Internal call sites acrosshkj-core(TryApplicative,TryTraverse,TryPath,PathOps,VTaskContext,LensExtensions,Affines),hkj-test(VTaskPathAssert,VTaskContextAssert),hkj-springreturn-value handlers,hkj-processor-plugins(theTryGeneratorannotation-processor template), and every runnable example, tutorial, and solution are migrated tofoldFailureFirst; library builds emit no deprecation warnings of their own.- The canonical name
foldis planned to be reintroduced on bothTryandTryPathwith the error-first argument order in 0.6.0, once 0.5.0 has removed the success-firstfoldand 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
SwapTryFoldToFoldFailureFirstRecipeis added to the existingMigrateDeprecationsTo0_5_0recipe group inhkj-openrewrite. The recipe matchesTry.fold(successMapper, failureMapper)andTryPath.fold(successMapper, failureMapper)call sites and rewrites them tofoldFailureFirst(failureMapper, successMapper), atomically renaming the method and swapping the two arguments. It cannot be expressed as a stockChangeMethodNameinvocation because the argument order changes; a naked rename would silently invert behaviour. Downstream consumers runorg.higherkindedj.openrewrite.MigrateDeprecationsTo0_5_0to migrateTry.fold,TryPath.fold,StateTKind.narrowK, andKindValidator.narrowWithPatternin one pass.