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:
ZEnvironment[R]is a type-indexed map, so every tagged service slot is a lawful Lens (service).- A
ZLayerwiring functionS => Ais exactlyCanGet[S, A], so sub-service layers project out of an aggregate service through the optic that names the field (focusLayer). zio.Refis sealed, so runtime state gets capability-driven extension methods (getFocus/updateFocus/setFocus) rather than a wrappedRef[A]view — which is the doctrine anyway: consume via capability, construct via optic.
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:
(ZEnvironment[R], A)for every tagged serviceAofR— theservicelens, soCanGet/CanModify/CanFoldand the derived capabilities;(Exit[E, A], A)— a success prism, soCanGetOption/CanModify/CanReverseGet; failed exits pass through writes untouched.
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.