Coming from cats-effect Resource, ZIO Scope/ZLayer, macwire or Using
This guide moves a small bookstore service from cats-effect Resource, ZIO Scope and ZLayer, macwire, or scala.util.Using to the ZIO Blocks scope module. By the end, the service's database connection and export file are opened, shared and closed by Scope, Resource and Wire, and the compiler rejects any attempt to use them after they close. Each source library gets its own section with the same three parts: the code you wrote, the code you write now, and what changes. Read the concept map, then jump to the section for the library you are leaving.
- Concept Map
- Coming from cats-effect
Resource - Coming from ZIO
ScopeandZLayer - Coming from macwire
- Coming from
scala.util.Using - What You Gain and What Is Different
- Migration Checklist
The Problem
All four libraries close resources for you, but none of them stops you from keeping a resource after it closes. A value returned from use, ZIO.scoped or Using.resource compiles fine and fails only when someone calls it. On top of that, lifecycle and wiring live in different places: macwire builds the object graph but closes nothing, Using closes things but builds no graph, and the effect systems need an effect type around every constructor.
Here is the leak with scala.util.Using, which needs nothing outside the standard library:
import java.util.concurrent.atomic.AtomicBoolean
import scala.util.Using
final class Connection extends AutoCloseable {
private val open = new AtomicBoolean(true)
def query(sql: String): String = {
require(open.get, "connection is closed")
s"rows for $sql"
}
def close(): Unit = open.set(false)
}
// Compiles: hands back a connection that is already closed
val leaked: Connection = Using.resource(new Connection)(identity)
The same shape compiles with db.use(IO.pure) in cats-effect and with ZIO.scoped(ZIO.fromAutoCloseable(...)) returning the resource in ZIO. Every one of them turns into a runtime failure later.
ZIO Blocks Scope ties each resource to a scope with a type, $[A], and lets a value leave the scope only when it has an Unscoped instance, which resources do not. Resource.from wires a graph from constructors and closes every AutoCloseable in it, in reverse order, when the scope closes.
Prerequisites
Add the scope module:
libraryDependencies += "dev.zio" %% "zio-blocks-scope" % "0.0.56"
For Scala.js, use %%%. The module supports Scala 2.13 and 3.x. It has no third-party dependencies (it pulls in only the ZIO Blocks context and chunk modules) and does not depend on cats-effect, ZIO or any other effect library.
Every ZIO Blocks snippet below starts from this import:
import zio.blocks.scope._
This guide assumes you already know one of the four source libraries. It maps their concepts onto ZIO Blocks rather than teaching Scope from scratch, so if Scope#scoped, Scope#allocate or Scope#$ are new to you, work through Compile-Time Resource Safety with Scope first.
The Bookstore Model
The running example is a bookstore service with this graph:
DbConfig ──► Db ──┬──► InventoryRepo ──┐
└──► PriceService ───┴──► Catalog
ExportFile (a second, independent resource)
Db is an AutoCloseable connection that both InventoryRepo and PriceService share. Catalog sits on top. ExportFile is a temporary CSV file that the service writes to and deletes when it is done. Each resource prints a line when it opens and closes, so the order is visible:
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Path, StandardOpenOption}
final case class DbConfig(url: String)
final class Db(config: DbConfig) extends AutoCloseable {
println(s"Db: connected to ${config.url}")
private val stock: Map[String, Int] = Map(
"Zionomicon" -> 3,
"Programming in Scala" -> 7,
"Functional Programming in Scala" -> 0
)
private val prices: Map[String, BigDecimal] = Map(
"Zionomicon" -> BigDecimal("49.99"),
"Programming in Scala" -> BigDecimal("59.95"),
"Functional Programming in Scala" -> BigDecimal("44.99")
)
def stockOf(title: String): Int = stock.getOrElse(title, 0)
def priceOf(title: String): BigDecimal = prices.getOrElse(title, BigDecimal(0))
def close(): Unit = println("Db: connection closed")
}
final class InventoryRepo(db: Db) {
def inStock(title: String): Int = db.stockOf(title)
}
final class PriceService(db: Db) {
def price(title: String): BigDecimal = db.priceOf(title)
}
final class Catalog(inventory: InventoryRepo, prices: PriceService) {
def describe(title: String): String =
s"$title: ${inventory.inStock(title)} in stock at $$${prices.price(title)}"
}
final class ExportFile(prefix: String) extends AutoCloseable {
private val path: Path = Files.createTempFile(prefix, ".csv")
println("ExportFile: created")
def write(line: String): Unit = {
Files.write(path, (line + "\n").getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND)
()
}
def lineCount: Int = Files.readAllLines(path).size
def close(): Unit = {
Files.deleteIfExists(path)
println("ExportFile: deleted")
}
}
Db and ExportFile are plain classes, not case classes. That matters on Scala 3, where every case class whose fields have Unscoped instances gets one too, so a case class resource could leave its scope. Keep resources as plain classes.
Concept Map
| Task | cats-effect 3 | ZIO 2 | macwire 2 | scala.util.Using | ZIO Blocks |
|---|---|---|---|---|---|
| Describe acquire and release | Resource.make(acq)(rel) | ZIO.acquireRelease(acq)(rel) | none | a Releasable[R] instance | Resource.acquireRelease(acq)(rel) |
Wrap an AutoCloseable | Resource.fromAutoCloseable(IO(r)) | ZIO.fromAutoCloseable(ZIO.attempt(r)) | none | built in | Resource.fromAutoCloseable(r) or allocate(r) |
| Use one resource | r.use(f) | ZIO.scoped(effect) | none | Using.resource(r)(f) | r.use(f) |
| Several resources, one block | for over resources, then use | ZIO.scoped around a for | none | Using.Manager { use => ... } | Scope.global.scoped { scope => ... } |
| Compose descriptions | map, flatMap | map, flatMap | none | none | map, flatMap, zip |
| Acquire in parallel | both, parTupled | zipPar | none | none | not supported; zip is sequential |
| Add a finalizer | Resource.onFinalize(fin) | ZIO.addFinalizer(fin) | none | none | defer(fin), cancel with DeferHandle.cancel() |
| Manage the lifetime by hand | allocated | Scope.make, then close(exit) | none | none | open(), then OpenScope.close() |
| Several finalizers fail | first error wins, rest reported | all kept in the Cause | none | one thrown, rest suppressed | first failure thrown, rest suppressed |
| Stop a resource escaping | no | no | no | no | compile error unless the type is Unscoped |
| Build from a constructor | none | ZLayer.fromFunction, ZLayer.derive | wire[A] | none | Wire.shared[A], Wire.unique[A] |
| Wire a whole graph | none | ZLayer.make[A](layers*) | autowire[A](deps*) (Scala 3) | none | Resource.from[A](wires*) |
| Shared or fresh instances | none | shared by default, ZLayer#fresh | one lazy val per instance | none | Wire.shared (default), Wire.unique |
| Close services in the graph | through Resource | through ZLayer.scoped | by hand | by hand | automatic for AutoCloseable |
Coming from cats-effect Resource
cats-effect Resource[F, A] and ZIO Blocks Resource[A] are both lazy descriptions of acquire and release that compose with flatMap and run with use. The ZIO Blocks one has no F[_]: acquisition and release are plain, synchronous Scala.
You Wrote This
This is the old code, cats-effect 3 (not compiled here):
import cats.effect.{IO, IOApp, Resource}
import java.nio.file.{Files, Path}
object Bookstore extends IOApp.Simple {
val db: Resource[IO, Db] =
Resource.fromAutoCloseable(IO(new Db(DbConfig("jdbc:h2:mem:bookstore"))))
val exportFile: Resource[IO, Path] =
Resource.make(IO.blocking(Files.createTempFile("catalog", ".csv")))(p =>
IO.blocking(Files.deleteIfExists(p)).void
)
// released in reverse order: exportFile first, then db
val resources: Resource[IO, (Db, Path)] =
for {
d <- db
f <- exportFile
} yield (d, f)
val run: IO[Unit] =
resources.use { case (d, file) =>
IO.blocking(Files.writeString(file, s"Zionomicon,${d.stockOf("Zionomicon")}")) *>
IO.println(s"exported to $file")
}
}
You Write This
Drop the IO wrappers. Resource.make becomes Resource.acquireRelease, Resource.fromAutoCloseable keeps its name, and the for comprehension and use work the same way:
val db: Resource[Db] =
Resource.fromAutoCloseable(new Db(DbConfig("jdbc:h2:mem:bookstore")))
val exportPath: Resource[Path] =
Resource.acquireRelease(Files.createTempFile("catalog", ".csv")) { p =>
Files.deleteIfExists(p)
println("export path: deleted")
}
val resources: Resource[(Db, Path)] =
for {
d <- db
f <- exportPath
} yield (d, f)
Nothing has run yet. use acquires both, runs the function, and releases them in reverse order:
val linesWritten: Int = resources.use { case (d, file) =>
Files.write(file, s"Zionomicon,${d.stockOf("Zionomicon")}".getBytes(StandardCharsets.UTF_8))
Files.readAllLines(file).size
}
// Db: connected to jdbc:h2:mem:bookstore
// export path: deleted
// Db: connection closed
// linesWritten: Int = 1
linesWritten is an Int, so it may leave use. The resource itself may not. This is the cats-effect db.use(IO.pure) leak, and it does not compile:
val escaped: Db = db.use(d => d)
// error:
// No given instance of type zio.blocks.scope.Unscoped[repl.MdocSession.MdocApp.Db] was found for parameter ev of method use in trait Resource.
// I found:
//
// zio.blocks.scope.Unscoped.derived[repl.MdocSession.MdocApp.Db](
// /* missing */summon[scala.deriving.Mirror.Of[repl.MdocSession.MdocApp.Db]])
//
// But Failed to synthesize an instance of type scala.deriving.Mirror.Of[repl.MdocSession.MdocApp.Db]:
// * class Db is not a generic product because it is not a case class
// * class Db is not a generic sum because it is not a sealed class.
// val escaped: Db = db.use(d => d)
// ^
What Changes
- No effect type.
Resource[A]has one type parameter. Acquire and release are by-name Scala expressions, soIO(...),IO.blocking(...)andResource.evalgo away: call the code directly. - The result of
usemust beUnscoped.use[B](f: A => B)requires anUnscoped[B]instance.Int,String, collections of them and Scala 3 case classes built from them have one. A plain class such asDbdoes not, so it cannot escape. - No parallel acquisition.
bothandparTupledhave no equivalent.zipacquires left then right and releases right then left. - No cancellation or async. A ZIO Blocks scope is synchronous and runs on the thread that opened it. If you rely on fibers, interruption or
Resource[IO, A]inside a concurrent program, keep cats-effect for that part. - Finalizer errors are kept. In cats-effect, when several releases fail, the first error wins and the rest go to the runtime's
reportFailure. A ZIO Blocks scope throws the first failure and attaches the others as suppressed exceptions. Thescala.util.Usingsection shows this. allocatedbecomesopen().Scope.global.open()returns anOpenScopewhoseclose()runs its finalizers and returns aFinalization, with noF[Unit]to forget.
Coming from ZIO Scope and ZLayer
ZIO splits resource handling into two pieces: ZIO.acquireRelease plus ZIO.scoped for local resources, and ZLayer for the service graph. ZIO Blocks has the same split: Scope#scoped plus Scope#allocate for local resources, and Wire plus Resource.from for the graph.
Scoped Resources
A local resource in ZIO lives inside ZIO.scoped; in ZIO Blocks it lives inside Scope.global.scoped.
You Wrote This
This is the old code, ZIO 2 (not compiled here):
import zio._
val program: Task[Int] =
ZIO.scoped {
for {
db <- ZIO.fromAutoCloseable(ZIO.attempt(new Db(DbConfig("jdbc:h2:mem:bookstore"))))
file <- ZIO.fromAutoCloseable(ZIO.attempt(new ExportFile("catalog")))
_ <- ZIO.addFinalizer(ZIO.succeed(println("export finished")))
_ <- ZIO.attempt(file.write(s"Zionomicon,${db.stockOf("Zionomicon")}"))
} yield file.lineCount
}
You Write This
ZIO.scoped becomes Scope.global.scoped, ZIO.fromAutoCloseable becomes Scope#allocate, and ZIO.addFinalizer becomes defer. Values come back as $[A], and $ opens them for one call:
val exportedLines: Int = Scope.global.scoped { scope =>
import scope._
val db = allocate(new Db(DbConfig("jdbc:h2:mem:bookstore")))
val file = allocate(new ExportFile("catalog"))
defer(println("export finished"))
$(db, file)((d, f) => f.write(s"Zionomicon,${d.stockOf("Zionomicon")}"))
$(file)(_.lineCount)
}
// Db: connected to jdbc:h2:mem:bookstore
// ExportFile: created
// export finished
// ExportFile: deleted
// Db: connection closed
// exportedLines: Int = 1
defer registered last, so it ran first. Then the export file closed, then the database, in reverse order of acquisition, as in ZIO.
The Service Graph
A ZIO service graph is a set of ZLayers assembled by ZLayer.make; in ZIO Blocks it is one Resource.from call.
You Wrote This
This is the old code, ZIO 2 (not compiled here):
import zio._
val dbLayer: ZLayer[DbConfig, Throwable, Db] =
ZLayer.scoped[DbConfig](ZIO.serviceWithZIO[DbConfig](c => ZIO.fromAutoCloseable(ZIO.attempt(new Db(c)))))
val catalogLayer: ZLayer[Any, Throwable, Catalog] =
ZLayer.make[Catalog](
ZLayer.succeed(DbConfig("jdbc:h2:mem:bookstore")),
dbLayer,
ZLayer.fromFunction(new InventoryRepo(_)),
ZLayer.fromFunction(new PriceService(_)),
ZLayer.fromFunction(new Catalog(_, _))
)
val run =
ZIO.serviceWith[Catalog](_.describe("Zionomicon")).debug.provide(catalogLayer)
You Write This
Resource.from[Catalog] reads the constructors itself, so you list only what it cannot build: values such as DbConfig (it never builds a String), and services you want to construct differently. Wire(value) is the ZLayer.succeed counterpart:
def catalogResource: Resource[Catalog] =
Resource.from[Catalog](Wire(DbConfig("jdbc:h2:mem:bookstore")))
Allocate it in a scope like any other resource. One Db serves both repositories, and it closes when the scope does:
val description: String = Scope.global.scoped { scope =>
import scope._
val catalog = allocate(catalogResource)
$(catalog)(_.describe("Zionomicon"))
}
// Db: connected to jdbc:h2:mem:bookstore
// Db: connection closed
// description: String = "Zionomicon: 3 in stock at $49.99"
Db extends AutoCloseable, so the macro registered its close(). There was no ZLayer.scoped step to remember.
When Resource.from cannot build the graph, the error names the type and the path that needed it. Leaving out the DbConfig wire fails because nothing can build its String field:
Resource.from[Catalog]()
// error:
// ── Scope Error ─────────────────────────────────────────────────────────────────
//
// Cannot auto-create java.lang.String
//
// This type (primitive, collection, or function) cannot be auto-created.
//
// Required by:
// ├── repl.MdocSession.MdocApp.DbConfig
// ├── repl.MdocSession.MdocApp.Db
// ├── repl.MdocSession.MdocApp.InventoryRepo
// └── repl.MdocSession.MdocApp.Catalog
//
// Fix: Provide Wire(value) with the desired value:
//
// Resource.from[...](
// Wire(...), // provide a value for java.lang.String
// ...
// )
//
// ────────────────────────────────────────────────────────────────────────────────
Both libraries check the graph at compile time: ZIO prints a ZLAYER ERROR box that lists the missing or ambiguous layers by number, while ZIO Blocks prints a Scope Error box with the tree of types that needed the missing one and a suggested Wire(...) fix.
Shared and Fresh Services
ZIO shares a layer across the graph by default and gives you ZLayer#fresh for a separate copy. ZIO Blocks works the same way: auto-built services and Wire.shared[A] are shared, and Wire.unique[A] builds a new instance for each dependent:
def catalogWithFreshPrices: Resource[Catalog] =
Resource.from[Catalog](
Wire(DbConfig("jdbc:h2:mem:bookstore")),
Wire.unique[PriceService]
)
ZLayer.derive[A] maps to Wire.shared[A]. One difference: ZLayer.derive does not close an AutoCloseable unless the class extends ZLayer.Derive.Scoped, while Wire.shared always registers close().
Manual Wires
ZLayer.fromFunction maps to Wire.Shared.fromFunction, for construction the macro cannot express. The function receives the Scope and a Context holding its inputs. A hand-written wire does not register close() for you, so defer it yourself:
val dbWire: Wire.Shared[DbConfig, Db] =
Wire.Shared.fromFunction[DbConfig, Db] { (scope, ctx) =>
val db = new Db(ctx.get[DbConfig])
scope.defer(db.close())
db
}
def catalogFromManualWire: Resource[Catalog] =
Resource.from[Catalog](Wire(DbConfig("jdbc:h2:mem:bookstore")), dbWire)
Allocating the resource builds the graph with the manual wire and prints the description:
catalogFromManualWire.use(_.describe("Programming in Scala"))
// Db: connected to jdbc:h2:mem:bookstore
// Db: connection closed
// res3: String = "Programming in Scala: 7 in stock at $59.95"
Wire(value) takes its argument by value. Wire(new Db(...)) opens the connection when the wire is created, not when a scope allocates it. Use Wire(...) for configuration values and let Resource.from, Wire.shared or Wire.Shared.fromFunction construct resources.
What Changes
- No environment type. There is no
RinResource[A]. The graph is resolved once, insideResource.from, and the result is an ordinaryResource[Catalog]. - Fewer layers to write.
Resource.frombuilds every concrete class it can reach from constructors. You list only leaf values and overrides, much likeZLayer.derivefor the whole graph. - Errors are exceptions.
Resource[A]has no error channel. A constructor that throws failsallocatewith that exception, and the scope still closes everything acquired so far. - Build a fresh graph for each run. Write
def catalogResource, notval. AResourcebuilt from shared wires counts its users and is used up once the last one closes; allocating the same value again throwsIllegalStateException: Cannot allocate from a destroyed shared resource. AZLayervalue can be provided again; aResource.fromvalue cannot. - Concurrency stays in ZIO. If services in the graph run fibers, use
ZLayerand ZIO'sScopefor them.
Coming from macwire
macwire is the closest match: wire[A] and Wire.shared[A] both read a constructor at compile time, with no runtime reflection. What macwire leaves out is lifecycle, and that is what ZIO Blocks adds.
You Wrote This
This is the old code, macwire 2 (not compiled here):
import com.softwaremill.macwire._
class BookstoreModule {
lazy val config: DbConfig = DbConfig("jdbc:h2:mem:bookstore")
lazy val db: Db = wire[Db]
lazy val inventory: InventoryRepo = wire[InventoryRepo]
lazy val prices: PriceService = wire[PriceService]
lazy val catalog: Catalog = wire[Catalog]
}
val module = new BookstoreModule
try println(module.catalog.describe("Zionomicon"))
finally module.db.close() // macwire has no lifecycle: closing is on you
On Scala 3, macwire 2 also has autowire, which builds the whole graph from the dependencies you pass it:
import com.softwaremill.macwire._
val catalog = autowire[Catalog](DbConfig("jdbc:h2:mem:bookstore"))
You Write This
autowire[Catalog](deps*) maps directly to Resource.from[Catalog](wires*), and it works on Scala 2.13 as well as Scala 3. The module of lazy vals is no longer needed, and neither is the try/finally:
Resource
.from[Catalog](Wire(DbConfig("jdbc:h2:mem:bookstore")))
.use(_.describe("Functional Programming in Scala"))
// Db: connected to jdbc:h2:mem:bookstore
// Db: connection closed
// res4: String = "Functional Programming in Scala: 0 in stock at $44.99"
To keep one named wire per service, as with wire[A], write Wire.shared[A] (or Wire.unique[A]) and pass the wires to Resource.from. Both libraries resolve dependencies by type, and Resource.from, like autowire, refuses to build a String or a primitive on its own.
What Lifecycle Adds
A macwire module closes nothing, so every AutoCloseable needs its own try/finally, written in reverse order by hand. With ZIO Blocks:
AutoCloseableservices close automatically, in reverse order of construction, when the scope closes, including when the body throws.- Services can register their own cleanup. A constructor that takes an implicit
Finalizer(orScope) gets one injected, and can calldefer:
final class CachedPriceService(db: Db)(implicit finalizer: Finalizer) {
defer(println("CachedPriceService: price cache flushed"))
def price(title: String): BigDecimal = db.priceOf(title)
}
final class CachedCatalog(inventory: InventoryRepo, prices: CachedPriceService) {
def describe(title: String): String =
s"$title: ${inventory.inStock(title)} in stock at $$${prices.price(title)}"
}
Wiring CachedCatalog flushes the price cache before the connection closes:
Resource
.from[CachedCatalog](Wire(DbConfig("jdbc:h2:mem:bookstore")))
.use(_.describe("Zionomicon"))
// Db: connected to jdbc:h2:mem:bookstore
// CachedPriceService: price cache flushed
// Db: connection closed
// res5: String = "Zionomicon: 3 in stock at $49.99"
The price cache was flushed before the connection closed, because CachedPriceService was built after Db.
- Services cannot outlive the graph. A macwire module hands out plain references that stay usable after you close
db. A ZIO Blocks scope hands out$[Catalog], which cannot leave the scope. - Sharing is explicit. In macwire, sharing follows from how many
lazy vals you write. In ZIO Blocks, shared is the default andWire.unique[A]asks for a new instance per dependent.
Coming from scala.util.Using
Using is in the standard library, so the old code on this page compiles too. Using.resource maps to Resource#use, and Using.Manager maps to Scope#scoped plus Scope#allocate.
You Wrote This
Using.Manager closes every resource passed to use when the block ends:
import scala.util.{Try, Using}
val managed: Try[Int] = Using.Manager { use =>
val db = use(new Db(DbConfig("jdbc:h2:mem:bookstore")))
val file = use(new ExportFile("catalog"))
file.write(s"Zionomicon,${db.stockOf("Zionomicon")}")
file.lineCount
}
// Db: connected to jdbc:h2:mem:bookstore
// ExportFile: created
// ExportFile: deleted
// Db: connection closed
// managed: Try[Int] = Success(1)
You Write This
The same export written with a scope:
val scopedLines: Int = Scope.global.scoped { scope =>
import scope._
val db = allocate(new Db(DbConfig("jdbc:h2:mem:bookstore")))
val file = allocate(new ExportFile("catalog"))
$(db, file)((d, f) => f.write(s"Zionomicon,${d.stockOf("Zionomicon")}"))
$(file)(_.lineCount)
}
// Db: connected to jdbc:h2:mem:bookstore
// ExportFile: created
// ExportFile: deleted
// Db: connection closed
// scopedLines: Int = 1
Both close in reverse order. Using.Manager returns a Try; Scope#scoped returns the value and throws if the body or a finalizer fails. Wrap the call in Try if you want the old shape back.
Finalizer Failures
Using and ZIO Blocks handle failing finalizers in nearly the same way. Using rethrows one exception and adds the others with addSuppressed, ranking fatal errors above ordinary ones. A ZIO Blocks scope throws the first failure in run order and adds the rest as suppressed; when the body itself throws, the body's exception wins and every finalizer failure is suppressed on it. Here are two resources whose close() fails:
final class FlakyResource(name: String) extends AutoCloseable {
def close(): Unit = throw new IllegalStateException(s"$name failed to close")
}
def describeFailure(t: Throwable): String =
s"${t.getMessage}; suppressed: ${t.getSuppressed.map(_.getMessage).mkString(", ")}"
With Using.Manager, the file closes first, so its failure is the one you get:
Using.Manager { use =>
use(new FlakyResource("db"))
use(new FlakyResource("export file"))
()
}.failed.foreach(t => println(describeFailure(t)))
// export file failed to close; suppressed: db failed to close
A scope gives the same answer:
Try {
Scope.global.scoped { scope =>
import scope._
allocate(new FlakyResource("db"))
allocate(new FlakyResource("export file"))
()
}
}.failed.foreach(t => println(describeFailure(t)))
// export file failed to close; suppressed: db failed to close
What Using has no counterpart for is collecting the failures without throwing. A scope from open() returns them as a Finalization when you close it:
val openScope = Scope.global.open()
openScope.scope.allocate(new FlakyResource("db"))
openScope.scope.allocate(new FlakyResource("export file"))
val finalization: Finalization = openScope.close()
close() threw nothing. Both failures are in finalization.errors, in the order the finalizers ran:
println(finalization.errors.map(_.getMessage).mkString(" | "))
// export file failed to close | db failed to close
From there, finalization.orThrow() throws the first error with the rest suppressed, and finalization.suppress(primary) attaches them all to an exception of your own.
What Changes
- Resources cannot escape.
Using.resource(r)(identity)compiles;Scope#allocatereturns a$[A], andScope#scopedrejects a result without anUnscopedinstance. - Use goes through
$. Inside the scope, call methods on a resource with$(db)(_.stockOf(title)). The lambda's parameter may appear only as the receiver of a method call, so you cannot pass the resource itself to other code. See$and the scoped value type for the rules. - More than resources.
deferadds any cleanup action,DeferHandle.cancel()removes it again, and nestedScope#scopedblocks plusScope#lowerlet a child scope use its parent's resources.
What You Gain and What Is Different
You gain:
- A compile-time check on escapes.
$[A]andUnscopedturn use-after-close into a compile error. None of the four source libraries offers this. - Lifecycle and wiring in one model. The constructor graph from macwire and the reverse-order cleanup from
Using, cats-effect and ZIO come from the sameResource. - A compile-time checked graph.
Resource.fromreports missing dependencies, ambiguous providers and cycles at compile time, likeZLayer.make. - No third-party dependencies and no effect type. The module runs on Scala 2.13 and 3, on the JVM and Scala.js, in direct-style code.
What is different:
- Synchronous only. No fibers, async acquisition, interruption or parallel
both. For those, stay on cats-effect or ZIO. - Child scopes belong to a thread. Only the thread that entered a
Scope#scopedblock can open child scopes on it; a scope fromopen()has no owner. - Results need
Unscoped. To return your own type fromScope#scopedorResource#use, it needs anUnscopedinstance (see below). Resource.fromvalues are single-use. Build them withdef.- Case classes are
Unscopedon Scala 3. Model resources as plain classes so the escape check covers them.
To return your own type from a scope, give it an Unscoped instance. On Scala 3, case classes whose fields are Unscoped get one automatically, and derives Unscoped makes it explicit; on Scala 2, derive it in the companion:
- Scala 2
- Scala 3
final case class StockReport(title: String, inStock: Int)
object StockReport {
implicit val unscoped: Unscoped[StockReport] = Unscoped.derived[StockReport]
}
final case class StockReport(title: String, inStock: Int) derives Unscoped
Returning StockReport from Scope#scoped now compiles because it has an Unscoped instance:
val report: StockReport = Scope.global.scoped { scope =>
import scope._
val catalog = allocate(Resource.from[InventoryRepo](Wire(DbConfig("jdbc:h2:mem:bookstore"))))
StockReport("Zionomicon", $(catalog)(_.inStock("Zionomicon")))
}
// Db: connected to jdbc:h2:mem:bookstore
// Db: connection closed
// report: StockReport = StockReport(title = "Zionomicon", inStock = 3)
println(report)
// StockReport(Zionomicon,3)
Migration Checklist
- Add
zio-blocks-scopeandimport zio.blocks.scope._. - Replace
Resource.make/ZIO.acquireReleasewithResource.acquireRelease, and cats-effect or ZIOfromAutoCloseablewith ZIO BlocksResource.fromAutoCloseableorallocate(new ...)(Scope#allocate). Remove the effect wrappers around acquire and release. - Replace
Resource#use/ZIO.scoped/Using.Managerblocks withScope.global.scoped { scope => import scope._; ... }and call resources through$. - Replace
Resource.onFinalize/ZIO.addFinalizerwithdefer. - Replace
allocated/Scope.makewithopen()andOpenScope.close(), and handle the returnedFinalization. - Replace
ZLayer.makeor a macwire module with oneResource.from[Root](wires*). PassWire(value)for configuration values; let the macro build the rest. - Replace
ZLayer#freshlayers withWire.unique[A], andZLayer.fromFunctionwithWire.Shared.fromFunction, deferringclose()yourself. - Turn
valresources built byResource.fromintodefs. - Make resource types plain classes, and give returned result types an
Unscopedinstance. - Delete the manual
try/finallyblocks that closed macwire-built services. - Keep cats-effect or ZIO for parts that need fibers, async resources or parallel acquisition.
Putting It Together
This is the bookstore fully migrated: Resource.from wires the graph with one shared Db, a service registers its own cleanup through Finalizer, the scope opens the export file as a second resource, and everything closes in reverse order:
package resourcemigration
import zio.blocks.scope._
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Path, StandardOpenOption}
/**
* The bookstore service graph from the "Coming from cats-effect Resource, ZIO
* Scope/ZLayer, macwire or Using" guide, fully migrated to ZIO Blocks Scope.
*
* `Resource.from[Catalog]` wires the graph from constructors, sharing one `Db`
* between `InventoryRepo` and `PriceService`. The scope opens a temporary
* export file as a second resource. When the scope closes, finalizers run in
* LIFO order: the export file is deleted first, then the price cache is
* flushed, then the database connection is closed.
*
* Run with: sbt "runMain resourcemigration.BookstoreMigratedExample"
*/
final case class DbConfig(url: String)
final class Db(config: DbConfig) extends AutoCloseable {
println(s"Db: connected to ${config.url}")
private val stock: Map[String, Int] = Map(
"Zionomicon" -> 3,
"Programming in Scala" -> 7,
"Functional Programming in Scala" -> 0
)
private val prices: Map[String, BigDecimal] = Map(
"Zionomicon" -> BigDecimal("49.99"),
"Programming in Scala" -> BigDecimal("59.95"),
"Functional Programming in Scala" -> BigDecimal("44.99")
)
def stockOf(title: String): Int = stock.getOrElse(title, 0)
def priceOf(title: String): BigDecimal = prices.getOrElse(title, BigDecimal(0))
def close(): Unit = println("Db: connection closed")
}
final class InventoryRepo(db: Db) {
def inStock(title: String): Int = db.stockOf(title)
}
// The implicit Finalizer lets a service register its own cleanup; the wiring
// macro injects it automatically.
final class PriceService(db: Db)(implicit finalizer: Finalizer) {
defer(println("PriceService: price cache flushed"))
def price(title: String): BigDecimal = db.priceOf(title)
}
final class Catalog(inventory: InventoryRepo, prices: PriceService) {
def describe(title: String): String =
s"$title: ${inventory.inStock(title)} in stock at $$${prices.price(title)}"
}
final class ExportFile(prefix: String) extends AutoCloseable {
private val path: Path = Files.createTempFile(prefix, ".csv")
println("ExportFile: created")
def write(line: String): Unit = {
Files.write(path, (line + "\n").getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND)
()
}
def lineCount: Int = Files.readAllLines(path).size
def close(): Unit = {
Files.deleteIfExists(path)
println("ExportFile: deleted")
}
}
object BookstoreMigratedExample {
val titles: List[String] =
List("Zionomicon", "Programming in Scala", "Functional Programming in Scala")
// A `def`, not a `val`: a Resource built from shared wires is single-use once
// its last scope has closed, so each run builds a fresh graph.
def catalogResource: Resource[Catalog] =
Resource.from[Catalog](Wire(DbConfig("jdbc:h2:mem:bookstore")))
def main(args: Array[String]): Unit = {
val exported: Int = Scope.global.scoped { scope =>
import scope._
val catalog = allocate(catalogResource)
val file = allocate(new ExportFile("catalog"))
titles.foreach { title =>
$(catalog, file)((c, f) => f.write(c.describe(title)))
}
$(file)(_.lineCount)
}
println(s"Exported $exported lines")
}
}
Running the Examples
The complete example is a runnable file in the scope-examples project. Clone the repository and change into the project:
git clone https://github.com/zio/zio-blocks.git
cd zio-blocks/scope-examples
scope-examples/src/main/scala/resourcemigration/BookstoreMigratedExample.scala
package resourcemigration
import zio.blocks.scope._
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Path, StandardOpenOption}
/**
* The bookstore service graph from the "Coming from cats-effect Resource, ZIO
* Scope/ZLayer, macwire or Using" guide, fully migrated to ZIO Blocks Scope.
*
* `Resource.from[Catalog]` wires the graph from constructors, sharing one `Db`
* between `InventoryRepo` and `PriceService`. The scope opens a temporary
* export file as a second resource. When the scope closes, finalizers run in
* LIFO order: the export file is deleted first, then the price cache is
* flushed, then the database connection is closed.
*
* Run with: sbt "runMain resourcemigration.BookstoreMigratedExample"
*/
final case class DbConfig(url: String)
final class Db(config: DbConfig) extends AutoCloseable {
println(s"Db: connected to ${config.url}")
private val stock: Map[String, Int] = Map(
"Zionomicon" -> 3,
"Programming in Scala" -> 7,
"Functional Programming in Scala" -> 0
)
private val prices: Map[String, BigDecimal] = Map(
"Zionomicon" -> BigDecimal("49.99"),
"Programming in Scala" -> BigDecimal("59.95"),
"Functional Programming in Scala" -> BigDecimal("44.99")
)
def stockOf(title: String): Int = stock.getOrElse(title, 0)
def priceOf(title: String): BigDecimal = prices.getOrElse(title, BigDecimal(0))
def close(): Unit = println("Db: connection closed")
}
final class InventoryRepo(db: Db) {
def inStock(title: String): Int = db.stockOf(title)
}
// The implicit Finalizer lets a service register its own cleanup; the wiring
// macro injects it automatically.
final class PriceService(db: Db)(implicit finalizer: Finalizer) {
defer(println("PriceService: price cache flushed"))
def price(title: String): BigDecimal = db.priceOf(title)
}
final class Catalog(inventory: InventoryRepo, prices: PriceService) {
def describe(title: String): String =
s"$title: ${inventory.inStock(title)} in stock at $$${prices.price(title)}"
}
final class ExportFile(prefix: String) extends AutoCloseable {
private val path: Path = Files.createTempFile(prefix, ".csv")
println("ExportFile: created")
def write(line: String): Unit = {
Files.write(path, (line + "\n").getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND)
()
}
def lineCount: Int = Files.readAllLines(path).size
def close(): Unit = {
Files.deleteIfExists(path)
println("ExportFile: deleted")
}
}
object BookstoreMigratedExample {
val titles: List[String] =
List("Zionomicon", "Programming in Scala", "Functional Programming in Scala")
// A `def`, not a `val`: a Resource built from shared wires is single-use once
// its last scope has closed, so each run builds a fresh graph.
def catalogResource: Resource[Catalog] =
Resource.from[Catalog](Wire(DbConfig("jdbc:h2:mem:bookstore")))
def main(args: Array[String]): Unit = {
val exported: Int = Scope.global.scoped { scope =>
import scope._
val catalog = allocate(catalogResource)
val file = allocate(new ExportFile("catalog"))
titles.foreach { title =>
$(catalog, file)((c, f) => f.write(c.describe(title)))
}
$(file)(_.lineCount)
}
println(s"Exported $exported lines")
}
}
Run it from the scope-examples directory:
sbt "runMain resourcemigration.BookstoreMigratedExample"
To check that the example compiles without running it:
sbt compile
See Also
- Compile-Time Resource Safety with Scope: the tutorial behind every concept on this page.
- Resource Management: how
Scope,ResourceandWirefit together. - Scope, Resource and Wire: the full API.
- Unscoped and Finalization: escape rules and finalizer errors.
- The how-to guide "How to fix common Scope and Wire compile errors", in progress, will explain each
Resource.fromand$compile error.