cats-eo

ZIO integration

The cats-eo-zio module integrates eo's capability layer with ZIO 2's dependency-injection model. No new carrier ships in this module — both ZIO's DI substrate and its wiring functions already have optic shapes:

libraryDependencies += "dev.constructive" %% "cats-eo-zio" % "0.13"

Which direction are you integrating?

The module is a two-way adapter; pick the row that matches what you are holding and what the other side expects:

You're holding The other side expects Reach for
an optic (lens[AppConfig](_.db)) a ZLayer in the dependency graph focusLayer — the optic becomes the layer's wiring function
an optic an environment transformation (overriding a dependency) service + .andThen handed to provideSomeEnvironment
a ZEnvironment, Exit, or Ref of either eo capability evidence (CanGet[T, A], CanModify[T, A], …) import dev.constructive.eo.zio.given — no hand-written given

The service lens

service[R, A] focuses the A service slot inside a ZEnvironment[R]. It is a lawful lens because ZEnvironment is a type-indexed map: writing a slot replaces exactly that entry and the sibling services are the untouched leftover.

import zio.*
import dev.constructive.eo.*
import dev.constructive.eo.zio.*
import dev.constructive.eo.generics.lens

case class Db(url: String, pool: Int)
case class Metrics(prefix: String)

val env = ZEnvironment(Db("jdbc:h2", 4)).add(Metrics("eo"))

val dbL = service[Db & Metrics, Db]
dbL.get(env)
// res0: Db = Db(url = "jdbc:h2", pool = 4)

Because service returns the concrete fused lens class, it composes with ordinary field optics through the same .andThen as everywhere else — drill from the environment into a field of a service:

val poolL = lens[Db](_.pool)
// poolL: SimpleLens[Db, Int, NamedTuple[*:["url", EmptyTuple], *:[String, EmptyTuple]]] = dev.constructive.eo.optics.SimpleLens@10c381c7

val dbPool = dbL.andThen(poolL)
// dbPool: Optic[ZEnvironment[Db & Metrics], ZEnvironment[Db & Metrics], Int, Int, [T1 >: Nothing <: Any, T2 >: Nothing <: Any] =>> Tuple2[T1, T2]] = repl.MdocSession$MdocApp$$anon$5@6c5b9b18

dbPool.modify(_ * 2)(env).get[Db]
// res1: Db = Db(url = "jdbc:h2", pool = 8)

That composed optic is what you hand to provideSomeEnvironment — ZIO's own idiom for providing a different implementation of a service: tweak one field deep inside one service, leave the rest of the environment alone. Where ZIO's docs override a whole service, the drilled optic overrides one field of one service:

val poolOf: ZIO[Db & Metrics, Nothing, Int] = ZIO.serviceWith[Db](_.pool)
// poolOf: ZIO[Db & Metrics, Nothing, Int] = Stateful(
//   trace = "repl.MdocSession.MdocApp.poolOf(zio.md:57)",
//   onState = zio.FiberRef$unsafe$PatchFiber$$Lambda$19251/0x00007f1b8ea9b3e8@4c86d548
// )

val throttled = poolOf.provideSomeEnvironment[Db & Metrics](dbPool.replace(1))
// throttled: ZIO[Db & Metrics, Nothing, Int] = Stateful(
//   trace = "repl.MdocSession.MdocApp.throttled(zio.md:61)",
//   onState = zio.FiberRef$unsafe$PatchFiber$$Lambda$19253/0x00007f1b8ea9c638@7ec364ff
// )

Unsafe.unsafe(implicit u =>
  Runtime.default.unsafe.run(throttled.provideEnvironment(env)).getOrThrowFiberFailure()
)
// res2: Int = 1

Layer projection

ZIO's idiom for deriving one service from another is a ZLayer whose construction function projects the dependency — see building the dependency graph and dependency propagation in the ZIO docs. That projection is a getter, so focusLayer[S, A] builds the layer from CanGet[S, A] evidence and slots into the same >>> / provide graphs as any hand-written layer — the optic is the wiring:

val urlL = lens[Db](_.url)

// One aggregate config service, one sub-service layer per field optic:
val urlLayer: ZLayer[Db, Nothing, String] = {
  given CanGet[Db, String] = urlL
  focusLayer[Db, String]
}
Unsafe.unsafe(implicit u =>
  Runtime.default.unsafe
    .run(ZIO.service[String].provideLayer(ZLayer.succeed(Db("jdbc:h2", 4)) >>> urlLayer))
    .getOrThrowFiberFailure()
)
// res3: String = "jdbc:h2"

serviceFocus[S, A] is the one-shot read of the same shape — ZIO.serviceWith[S] routed through CanGet instead of an ad-hoc projection lambda.

Ref focus ops

Runtime state held in a Ref[S] gets the same capability treatment. Each op demands the weakest capability it needs, and a read-then-write is ONE CanModify in a single atomic Ref.update pass — never split get + set evidence:

val program =
  for
    ref <- Ref.make(Db("jdbc:h2", 4))
    _   <- ref.updateFocus[String](_.toUpperCase)(using urlL)
    _   <- ref.setFocus(8)(using poolL)
    out <- ref.get
  yield out
// program: ZIO[Any, Nothing, Db] = FlatMap(
//   trace = "repl.MdocSession.MdocApp.program(zio.md:104)",
//   first = Sync(
//     trace = "repl.MdocSession.MdocApp.program(zio.md:100)",
//     eval = zio.Ref$$$Lambda$19338/0x00007f1b8eacb778@31e1e138
//   ),
//   successK = repl.MdocSession$MdocApp$$Lambda$19339/0x00007f1b8eab1530@5df77946
// )

Unsafe.unsafe(implicit u =>
  Runtime.default.unsafe.run(program).getOrThrowFiberFailure()
)
// res4: Db = Db(url = "JDBC:H2", pool = 8)

getFocus (via CanGet) and getFocusOption (via CanGetOption, for Prism / Optional / AffineFold evidence) complete the read side.

Automatic capability givens

ZIO's types can also provide eo capabilities by themselves. A given import (Scala 3's * wildcard deliberately does not pull givens) puts one optic given per pair in scope:

Generic capability-consuming code then accepts ZIO subjects with no hand-written given:

import dev.constructive.eo.zio.given

def report[T](t: T)(using g: CanGet[T, Db]): String = g.get(t).url
def bump[T](t: T)(using m: CanModify[T, Int]): T = m.modify(_ + 1)(t)

report(env)
// res5: String = "jdbc:h2"

bump(Exit.succeed(41))
// res6: Exit[Nothing, Int] = Success(42)

bump(Exit.fail("boom"): Exit[String, Int])
// res7: Exit[String, Int] = Failure(
//   Fail(
//     value = "boom",
//     trace = StackTrace(fiberId = None, stackTrace = IndexedSeq())
//   )
// )

Coherence rule as everywhere in eo: these are THE optic givens for their (S, A) pairs — don't declare competing ones.

Effectful modify

There is deliberately no modifyZIO in this module. CanModifyF.modifyF is polymorphic in its functor, and zio-interop-cats owns the cats.Functor[ZIO[R, E, *]] instance — so the integration is one import on your side of the classpath, with nothing for this module to duplicate:

// libraryDependencies += "dev.zio" %% "zio-interop-cats" % "23.1.0.5"
import zio.interop.catz.*

def refresh(url: String): Task[String] = ???

val refreshed: Db => Task[Db] = urlL.modifyF(refresh)

Overhead

ZioDiBench pairs every op above (leaf service get/replace, drilled get/modify) against the hand-written env.get / env.update equivalent. Both sides pay ZEnvironment's own map machinery — and the optic side measures cheaper: reads are allocation-free (0 B/op vs ~120 B/op hand-written) and writes allocate less, because the lens captures its zio.Tag once at construction while every hand-written env.get[A] / env.update[A] call site re-materializes it. See the benchmarks page for the current sweep.