Overview
Quick Start
Dependencies
Add the distage-core
library:
- sbt
libraryDependencies += "io.7mind.izumi" %% "distage-core" % "1.2.16-SNAPSHOT"
If you’re using Scala 3 you must enable -Yretain-trees
for this library to work correctly:
// REQUIRED option for Scala 3
scalacOptions += "-Yretain-trees"
If you’re using Scala 2.12
you must enable -Ypartial-unification
and either -Xsource:2.13
or -Xsource:3
for this library to work correctly:
// REQUIRED options for Scala 2.12
scalacOptions += "-Ypartial-unification"
scalacOptions += "-Xsource:3" // or "-Xsource:2.13" if absolutely necessary
Additionally, some source examples in this document use underscore syntax for type lambdas which you can enable with the following options:
// For Scala 2
scalacOptions += "-P:kind-projector:underscore-placeholders"
scalacOptions += "-Xsource:3"
addCompilerPlugin("org.typelevel" % "kind-projector" % "0.13.3" cross CrossVersion.full)
// For Scala 3
scalacOptions += "-Ykind-projector:underscores"
Hello World example
Suppose we have an abstract Greeter
component, and some other components that depend on it:
import zio.Task
import zio.Console.{printLine, readLine}
trait Greeter {
def hello(name: String): Task[Unit]
}
final class PrintGreeter extends Greeter {
override def hello(name: String) =
printLine(s"Hello $name!")
}
trait Byer {
def bye(name: String): Task[Unit]
}
final class PrintByer extends Byer {
override def bye(name: String) =
printLine(s"Bye $name!")
}
final class HelloByeApp(
greeter: Greeter,
byer: Byer,
) {
def run: Task[Unit] = {
for {
_ <- printLine("What's your name?")
name <- readLine
_ <- greeter.hello(name)
_ <- byer.bye(name)
} yield ()
}
}
To actually run the HelloByeApp
, we have to wire implementations of Greeter
and Byer
into it. We will not do it directly. First we’ll only declare the component interfaces we have, and the implementations we want for them:
import distage.ModuleDef
def HelloByeModule = new ModuleDef {
make[Greeter].from[PrintGreeter]
make[Byer].from[PrintByer]
make[HelloByeApp] // `.from` is not required for concrete classes
}
ModuleDef
merely contains a description of the desired object graph, let’s transform that high-level description into an actionable series of steps - a Plan
, a datatype we can inspect, test or verify at compile-time – without having to actually create objects or execute effects.
import distage.{Activation, Injector, Roots}
val injector = Injector[Task]()
// injector: Injector[Task] = izumi.distage.InjectorDefaultImpl@82c7b86
val plan = injector.plan(HelloByeModule, Roots.target[HelloByeApp], Activation.empty).getOrThrow()
// plan: izumi.distage.model.plan.Plan = 1: {type.MdocSession::MdocApp1::Greeter} BindingOrigin((basics.md:84)) := call(π:Constructor(): MdocSession::MdocApp1::PrintGreeter[32m) {}
// 2: {type.MdocSession::MdocApp1::Byer} BindingOrigin((basics.md:85)) := call(π:Constructor(): MdocSession::MdocApp1::PrintByer[32m) {}
// 3: {type.MdocSession::MdocApp1::HelloByeApp} BindingOrigin((basics.md:86)) :=
// 4: call(π:Constructor(MdocSession::MdocApp1::Greeter, MdocSession::MdocApp1::Byer): MdocSession::MdocApp1::HelloByeApp[32m) {
// 5: arg greeter: MdocSession::MdocApp1::Greeter <- {type.MdocSession::MdocApp1::Greeter}
// 6: arg byer: MdocSession::MdocApp1::Byer <- {type.MdocSession::MdocApp1::Byer}
// 7: }
The series of steps must be executed to produce the object graph.
Injector.produce
will interpret the steps into a Lifecycle
value holding the lifecycle of the object graph:
import izumi.functional.bio.UnsafeRun2
// Interpret into a Lifecycle value
val resource = injector.produce(plan)
// resource: izumi.distage.model.definition.package.Lifecycle[Task, izumi.distage.model.Locator] = izumi.functional.lifecycle.LifecycleMethodImpls$$anon$2@5dd30c9b
// Use the object graph:
// After `.use` exits, all objects will be deallocated,
// and all allocated resources will be freed.
val effect = resource.use {
objects =>
objects.get[HelloByeApp].run
}
// effect: Task[Unit] = DynamicNoBox(izumi.functional.quasi.QuasiPrimitivesFromBIO.bracket(QuasiIO.scala:339),1,zio.ZIO$Release$$Lambda/0x00007fff8346caf0@2417d6e2)
// Run the resulting program
val runner = UnsafeRun2.createZIO()
// runner: UnsafeRun2.ZIORunner[Any] = izumi.functional.bio.UnsafeRun2$ZIORunner@74def854
runner.unsafeRun(effect)
// What's your name?
// > izumi
// Hello izumi!
// Bye izumi!
Singleton components
distage
creates components at most once, even if multiple other objects depend on them.
A given component X
will be the same X
everywhere in the object graph, i.e. a singleton.
It’s impossible to create non-singletons in distage
.
Named components
If you need multiple singleton instances of the same type, you may create “named” instances and disambiguate between them using `@distage.Id`
annotation. (javax.inject.Named
is also supported)
import distage.Id
def negateByer(otherByer: Byer): Byer = {
new Byer {
def bye(name: String) =
otherByer.bye(s"NOT-$name")
}
}
new ModuleDef {
make[Byer].named("byer-1").from[PrintByer]
make[Byer].named("byer-2").from {
(otherByer: Byer @Id("byer-1")) =>
negateByer(otherByer)
}
}
You can use make[_].annotateParameter
method instead of an annotation, to attach a name component to an existing constructor:
new ModuleDef {
// same binding as above
make[Byer].named("byer-2")
.from(negateByer(_))
.annotateParameter[Byer]("byer-1")
}
You can also abstract over annotations using type aliases and/or string constants (final val
):
object Ids {
final val byer1Id = "byer-1"
type Byer1 = Byer @Id(byer1Id)
}
Non-singleton components
You cannot embed non-singletons into the object graph, but you may create them as normal using factories. distage
’s Auto-Factories can generate implementations for your factories, removing the associated boilerplate.
While Auto-Factories may remove the boilerplate of generating factories for singular components, if you need to create a new non-trivial subgraph dynamically, you’ll need to run Injector
again. Subcontexts feature automates running nested Injectors and makes it easier to define nested object graphs. You may also manually use Injector.inherit
to reuse components from the outer object graph in your new nested object graph, see Injector Inheritance.
Real-world example
Check out distage-example
sample project for a complete example built using distage
, bifunctor tagless final, http4s
, doobie
and zio
libraries.
It shows how to write an idiomatic distage
-style from scratch and how to:
- write tests using
distage-testkit
- setup portable test environments using
distage-framework-docker
- create role-based applications
- enable compile-time checks for fast feedback on wiring errors
Activation Axis
You can choose between different implementations of a component using “Activation axis”:
import distage.{Axis, Activation, ModuleDef, Injector}
import zio.Console
class AllCapsGreeter extends Greeter {
def hello(name: String) =
Console.printLine(s"HELLO ${name.toUpperCase}")
}
// declare a configuration axis for our components
object Style extends Axis {
case object AllCaps extends AxisChoiceDef
case object Normal extends AxisChoiceDef
}
// Declare a module with several implementations of Greeter
// but in different environments
def TwoImplsModule = new ModuleDef {
make[Greeter].tagged(Style.Normal)
.from[PrintGreeter]
make[Greeter].tagged(Style.AllCaps)
.from[AllCapsGreeter]
}
// Combine previous `HelloByeModule` with our new module
// While overriding `make[Greeter]` bindings from the first module
def CombinedModule = HelloByeModule overriddenBy TwoImplsModule
// Choose component configuration when making an Injector:
runner.unsafeRun {
Injector()
.produceGet[HelloByeApp](CombinedModule, Activation(Style -> Style.AllCaps))
.use(_.run)
}
// What's your name?
// > kai
// HELLO KAI
// Bye kai!
// Check that result changes with a different configuration:
runner.unsafeRun {
Injector()
.produceGet[HelloByeApp](CombinedModule, Activation(Style -> Style.Normal))
.use(_.run)
}
// What's your name?
// > Pavel
// Hello Pavel!
// Bye Pavel!
distage.StandardAxis
contains bundled Axes for back-end development:
-
Repo
axis, withProd
/Dummy
choices, describes any entities which may store and persist state or “repositories”. e.g. databases, message queues, KV storages, file systems, etc. Those may typically have both in-memoryDummy
implementations and heavyweightProd
implementations using external databases. -
Mode
axis, withProd
/Test
choices, describes a generic choice between production and test implementations of a component. -
World
axis, withReal
/Mock
choices, describes third-party integrations which are not controlled by the application and provided “as is”. e.g. Facebook API, Google API, etc. those may contact aReal
external integration or aMock
one with predefined responses. -
Scene
axis withManaged
/Provided
choices, describes whether external services required by the application should be set-up on the fly by an orchestrator library such asdistage-framework-docker
(Scene.Managed
), or whether the application should try to connect to external services as if they already exist in the environment (Scene.Provided
). We call a set of external services required by the application aScene
, etymology being that the running external services required by the application are like a “scene” that the “staff” (the orchestrator) must prepare for the “actor” (the application) to enter.
In distage-framework
’s RoleAppMain
, you can choose axes using the -u
command-line parameter:
./launcher -u repo:dummy -u env:prod app1
In distage-testkit
, choose axes using TestConfig
:
import distage.StandardAxis.Repo
import izumi.distage.testkit.TestConfig
import izumi.distage.testkit.scalatest.Spec2
class AxisTest extends Spec2[zio.IO] {
// choose implementations `.tagged` as `Repo.Dummy` over those tagged `Repo.Prod`
override def config: TestConfig = super.config.copy(
activation = Activation(Repo -> Repo.Dummy)
)
}
Multi-dimensionality
There may be many configuration axes in an application and components can specify multiple axis choices at once:
import distage.StandardAxis.Mode
import zio.Console
class TestPrintGreeter extends Greeter {
def hello(name: String) =
Console.printLine(s"Test 1 2, hello $name")
}
// declare 3 possible implementations
def TestModule = new ModuleDef {
make[Greeter].tagged(Style.Normal, Mode.Prod).from[PrintGreeter]
make[Greeter].tagged(Style.Normal, Mode.Test).from[TestPrintGreeter]
make[Greeter].tagged(Style.AllCaps).from[AllCapsGreeter]
}
def runWith(activation: Activation) = {
runner.unsafeRun {
Injector().produceRun(TestModule, activation) {
(greeter: Greeter) => greeter.hello("$USERNAME")
}
}
}
// Production Normal Greeter
runWith(Activation(Style -> Style.Normal, Mode -> Mode.Prod))
// Hello $USERNAME!
// Test Normal Greeter
runWith(Activation(Style -> Style.Normal, Mode -> Mode.Test))
// Test 1 2, hello $USERNAME
// Both Production and Test Caps Greeters are the same:
runWith(Activation(Style -> Style.AllCaps, Mode -> Mode.Prod))
// HELLO $USERNAME
runWith(Activation(Style -> Style.AllCaps, Mode -> Mode.Test))
// HELLO $USERNAME
Specificity and defaults
When multiple dimensions are attached to a binding, bindings with less specified dimensions will be considered less specific and will be overridden by bindings with more dimensions, if all of those dimensions are explicitly set.
A binding with no attached dimensions is considered a “default” vs. a binding with attached dimensions. A default will be chosen only if all other bindings are explicitly contradicted by passed activations. If the dimensions for other bindings are merely unset, it will cause an ambiguity error.
Example of these rules:
import scala.util.Try
sealed trait Color
case object RED extends Color
case object Blue extends Color
case object Green extends Color
// Defaults:
def DefaultsModule = new ModuleDef {
make[Color].from(Green)
make[Color].tagged(Style.AllCaps).from(RED)
}
Injector().produceRun(DefaultsModule, Activation(Style -> Style.AllCaps))(println(_: Color))
// RED
// res11: distage.package.Identity[Unit] = ()
Injector().produceRun(DefaultsModule, Activation(Style -> Style.Normal))(println(_: Color))
// Green
// res12: distage.package.Identity[Unit] = ()
// ERROR Ambiguous without Style
Try { Injector().produceRun(DefaultsModule, Activation.empty)(println(_: Color)) }.isFailure
// res13: Boolean = true
// Specificity
def SpecificityModule = new ModuleDef {
make[Color].tagged(Mode.Test).from(Blue)
make[Color].tagged(Mode.Prod).from(Green)
make[Color].tagged(Mode.Prod, Style.AllCaps).from(RED)
}
Injector().produceRun(SpecificityModule, Activation(Mode -> Mode.Prod, Style -> Style.AllCaps))(println(_: Color))
// RED
// res14: distage.package.Identity[Unit] = ()
Injector().produceRun(SpecificityModule, Activation(Mode -> Mode.Test, Style -> Style.AllCaps))(println(_: Color))
// Blue
// res15: distage.package.Identity[Unit] = ()
Injector().produceRun(SpecificityModule, Activation(Mode -> Mode.Prod, Style -> Style.Normal))(println(_: Color))
// Green
// res16: distage.package.Identity[Unit] = ()
Injector().produceRun(SpecificityModule, Activation(Mode -> Mode.Test))(println(_: Color))
// Blue
// res17: distage.package.Identity[Unit] = ()
// ERROR Ambiguous without Mode
Try { Injector().produceRun(SpecificityModule, Activation(Style -> Style.Normal))(println(_: Color)) }.isFailure
// res18: Boolean = true
Resource Bindings, Lifecycle
You can specify object lifecycle by injecting distage.Lifecycle
, cats.effect.Resource, scoped zio.ZIO, zio.ZLayer or zio.managed.ZManaged values specifying the allocation and finalization actions of an object.
When ran, distage Injector
itself returns a Lifecycle
value that describes actions to create and finalize the object graph; the Lifecycle
value is pure and can be reused multiple times.
A Lifecycle
is executed using its .use
method, the function passed to use
will receive an allocated resource and when the function exits the resource will be deallocated. Lifecycle
is generally not invalidated after .use
and may be executed multiple times.
Example with cats.effect.Resource
:
import distage.{Roots, ModuleDef, Injector}
import cats.effect.{Resource, IO}
class DBConnection
class MessageQueueConnection
val dbResource = Resource.make(
acquire = IO {
println("Connecting to DB!")
new DBConnection
})(release = _ => IO(println("Disconnecting DB")))
// dbResource: Resource[IO, DBConnection] = Allocate(cats.effect.kernel.Resource$$$Lambda/0x00007fff835413d8@466928dc)
val mqResource = Resource.make(
acquire = IO {
println("Connecting to Message Queue!")
new MessageQueueConnection
})(release = _ => IO(println("Disconnecting Message Queue")))
// mqResource: Resource[IO, MessageQueueConnection] = Allocate(cats.effect.kernel.Resource$$$Lambda/0x00007fff835413d8@42bc70b3)
class MyApp(
db: DBConnection,
mq: MessageQueueConnection,
) {
val run = {
IO(println("Hello World!"))
}
}
def module = new ModuleDef {
make[DBConnection].fromResource(dbResource)
make[MessageQueueConnection].fromResource(mqResource)
make[MyApp]
}
Will produce the following output:
import cats.effect.unsafe.implicits.global
val objectGraphResource = {
Injector[IO]()
.produce(module, Roots.target[MyApp])
}
// objectGraphResource: izumi.distage.model.definition.package.Lifecycle[IO, izumi.distage.model.Locator] = izumi.functional.lifecycle.LifecycleMethodImpls$$anon$2@abf9f83
objectGraphResource
.use(_.get[MyApp].run)
.unsafeRunSync()
// Connecting to DB!
// Connecting to Message Queue!
// Hello World!
// Disconnecting Message Queue
// Disconnecting DB
Lifecycle management with Lifecycle
is also available without an effect type, via Lifecycle.Simple
and Lifecycle.Mutable
:
import distage.{Lifecycle, ModuleDef, Injector}
class Init {
var initialized = false
}
class InitResource extends Lifecycle.Simple[Init] {
override def acquire = {
val init = new Init
init.initialized = true
init
}
override def release(init: Init) = {
init.initialized = false
}
}
def module = new ModuleDef {
make[Init].fromResource[InitResource]
}
val closedInit = Injector()
.produceGet[Init](module)
.use {
init =>
println(init.initialized)
init
}
// true
// closedInit: distage.package.Identity[Init] = repl.MdocSession$MdocApp21$Init@3dc2274f
println(closedInit.initialized)
// false
Lifecycle
forms a monad and has the expected .map
, .flatMap
, .evalMap
, .mapK
methods.
You can convert between a Lifecycle
and cats.effect.Resource
via Lifecycle#toCats
/Lifecycle.fromCats
methods, and between a Lifecycle
and scoped zio.ZIO
/zio.managed.ZManaged
/zio.ZLayer
via Lifecycle#toZIO
/Lifecycle.fromZIO
methods.
Inheritance helpers
The following helpers allow defining Lifecycle
subclasses using expression-like syntax:
Lifecycle.Of
Lifecycle.OfInner
Lifecycle.OfCats
Lifecycle.OfZIO
Lifecycle.OfZManaged
Lifecycle.OfZLayer
Lifecycle.LiftF
Lifecycle.Make
Lifecycle.Make_
Lifecycle.MakePair
Lifecycle.FromAutoCloseable
Lifecycle.SelfOf
Lifecycle.MutableOf
The main reason to employ them is to work around a limitation in Scala 2’s eta-expansion — when converting a method to a function value, Scala always tries to fulfill implicit parameters eagerly instead of making them parameters of the function value, this limitation makes it harder to inject implicits using distage
.
However, when using distage
’s type-based syntax: make[A].fromResource[A.Resource[F]]
— this limitation does not apply and implicits inject successfully.
So to work around this limitation you can convert an expression based resource constructor:
import distage.{Lifecycle, ModuleDef}
import cats.Monad
class A(val n: Int)
object A {
def resource[F[_]: Monad]: Lifecycle[F, A] =
Lifecycle.pure[F](new A(1))
}
def module = new ModuleDef {
// Bad: summons Monad[cats.effect.IO] immediately, instead of getting it from the object graph
make[A].fromResource(A.resource[cats.effect.IO])
}
Into a class-based form:
import distage.{Lifecycle, ModuleDef}
import cats.Monad
class A(val n: Int)
object A {
final class Resource[F[_]: Monad]
extends Lifecycle.Of[F, A](
Lifecycle.pure[F](new A(1))
)
}
def module = new ModuleDef {
// Good: implicit Monad[cats.effect.IO] parameter is wired from the object graph, same as the non-implicit parameters
make[A].fromResource[A.Resource[cats.effect.IO]]
addImplicit[Monad[cats.effect.IO]]
}
And inject successfully using make[A].fromResource[A.Resource[F]]
syntax of ModuleDefDSL
.
The following helpers ease defining Lifecycle
subclasses using traditional inheritance where acquire
/release
parts are defined as methods:
Lifecycle.Basic
Lifecycle.Simple
Lifecycle.Mutable
Lifecycle.MutableNoClose
Lifecycle.Self
Lifecycle.SelfNoClose
Lifecycle.NoClose
Out-of-the-box typeclass instances
Typeclass instances for popular typeclass hierarchies are included by default for the effect type in which distage
is running.
Whenever your effect type implements BIO or cats-effect typeclasses, their instances will be summonable without adding them into modules. This applies for ZIO
, cats.effect.IO
, monix
, monix-bio
and any other effect type with relevant typeclass instances in implicit scope.
- For
ZIO
,monix-bio
and any other implementors of BIO typeclasses,BIO
hierarchy instances will be included. - For
ZIO
,cats-effect
instances will be included only if ZIOinterop-cats
library is on the classpath.
Example usage:
import cats.effect.{IO, Sync}
import distage.{Activation, DefaultModule, Injector, Module, TagK}
import izumi.functional.quasi.QuasiIO
def polymorphicHelloWorld[F[_]: TagK: QuasiIO: DefaultModule]: F[Unit] = {
Injector[F]().produceRun(
Module.empty, // we do not define _any_ components
Activation.empty,
) {
(F: Sync[F]) => // cats.effect.Sync[F] is available anyway
F.delay(println("Hello world!"))
}
}
val catsEffectHello = polymorphicHelloWorld[cats.effect.IO]
// catsEffectHello: IO[Unit] = IO(...)
//val monixHello = polymorphicHelloWorld[monix.eval.Task]
val zioHello = polymorphicHelloWorld[zio.IO[Throwable, _]]
// zioHello: zio.ZIO[Any, Throwable, Unit] = DynamicNoBox(izumi.functional.quasi.QuasiPrimitivesFromBIO.bracket(QuasiIO.scala:339),1,zio.ZIO$Release$$Lambda/0x00007fff8346caf0@1021da34)
//val monixBioHello = polymorphicHelloWorld[monix.bio.IO[Throwable, _]]
See `DefaultModule`
implicit for implementation details. For details on what exact components are available for each effect type, see ZIOSupportModule
, CatsIOSupportModule
, MonixSupportModule
, MonixBIOSupportModule
, ZIOCatsEffectInstancesModule
, respectively.
DefaultModule occurs as an implicit parameter in distage
entrypoints that require an effect type parameter, namely: Injector[F]()
in distage-core
, extends RoleAppMain[F]
and extends PlanCheck.Main[F]
in distage-framework
and extends Spec1[F]
in distage-testkit
.
Set Bindings
Set bindings are useful for implementing listeners, plugins, hooks, http routes, healthchecks, migrations, etc. Everywhere where a collection of components is required, a Set Binding is appropriate.
To define a Set binding use .many
and .add
methods of the ModuleDef
DSL.
As an example, we may declare multiple command handlers and use them to interpret user input in a REPL
import distage.ModuleDef
final case class CommandHandler(
handle: PartialFunction[String, String]
)
val additionHandler = CommandHandler {
case s"$x + $y" => s"${x.toInt + y.toInt}"
}
// additionHandler: CommandHandler = CommandHandler(<function1>)
object AdditionModule extends ModuleDef {
many[CommandHandler]
.add(additionHandler)
}
We’ve used many
method to declare an open Set
of command handlers and then added one handler to it.
When module definitions are combined, elements for the same type of Set
will be merged together into a larger set.
You can summon a Set binding by summoning a scala Set
, as in Set[CommandHandler]
.
Let’s define a new module with another handler:
val subtractionHandler = CommandHandler {
case s"$x - $y" => s"${x.toInt - y.toInt}"
}
// subtractionHandler: CommandHandler = CommandHandler(<function1>)
object SubtractionModule extends ModuleDef {
many[CommandHandler]
.add(subtractionHandler)
}
Let’s create a command-line application using our command handlers:
import distage.Injector
trait App {
def interpret(input: String): String
}
object App {
final class Impl(
handlers: Set[CommandHandler]
) extends App {
override def interpret(input: String): String = {
handlers.map(_.handle).reduce(_ orElse _).lift(input) match {
case Some(answer) => s"ANSWER: $answer"
case None => "?"
}
}
}
}
object AppModule extends ModuleDef {
// include all the previous module definitions
include(AdditionModule)
include(SubtractionModule)
// add a help handler
many[CommandHandler].add(CommandHandler {
case "help" => "Please input an arithmetic expression!"
})
// bind App
make[App].from[App.Impl]
}
// wire the graph and get the app
val app = Injector().produceGet[App](AppModule).unsafeGet()
// app: App = repl.MdocSession$MdocApp26$App$Impl@a8386b5
// check how it works
app.interpret("1 + 5")
// res27: String = "ANSWER: 6"
app.interpret("7 - 11")
// res28: String = "ANSWER: -4"
app.interpret("1 / 3")
// res29: String = "?"
app.interpret("help")
// res30: String = "ANSWER: Please input an arithmetic expression!"
If we rewire the app without SubtractionModule
, it will expectedly lose the ability to subtract:
Injector().produceRun(AppModule -- SubtractionModule.keys) {
(app: App) =>
app.interpret("10 - 1")
}
// res31: String = "?"
Further reading:
- Guice calls the same concept “Multibindings”.
Mutator Bindings
Mutations can be attached to any component using modify[X]
keyword.
If present, they will be applied in an undefined order after the component has been created, but before it is visible to any other component.
Mutators provide a way to do partial overrides or slight modifications of some existing component without redefining it fully.
Example:
import distage.{Id, Injector, ModuleDef}
def startingModule = new ModuleDef {
make[Int].fromValue(1) // 1
}
def increment2 = new ModuleDef {
modify[Int](_ + 1) // 2
modify[Int](_ + 1) // 3
}
def incrementWithDep = new ModuleDef {
make[String].fromValue("hello")
make[Int].named("a-few").fromValue(2)
// mutators may use other components and add additional dependencies
modify[Int].by(_.flatAp {
(s: String, few: Int @Id("a-few")) => (currentInt: Int) =>
s.length + few + currentInt
}) // 5 + 2 + 3
}
Injector().produceRun(
startingModule ++
increment2 ++
incrementWithDep
)((currentInt: Int) => currentInt): Int
// res33: Int = 10
Another example: Suppose you’re using a config case class in your distage-testkit
tests, and for one of the test you want to use a modified value for one of the fields in it. Before 1.0 you’d have to duplicate the config binding into a new key and apply the modifying function to it:
import distage.{Id, ModuleDef}
import distage.config.ConfigModuleDef
import izumi.distage.testkit.TestConfig
import izumi.distage.testkit.scalatest.SpecIdentity
class MyTest extends SpecIdentity {
override def config: TestConfig = super.config.copy(
moduleOverrides = new ConfigModuleDef {
makeConfig[Config]("config.myconfig").named("duplicate")
make[Config].from {
(thatConfig: Config @Id("duplicate")) =>
modifyingFunction(thatConfig)
}
}
)
}
Now instead of overriding the entire binding, we may use a mutator:
class MyTest extends SpecIdentity {
override def config: TestConfig = super.config.copy(
moduleOverrides = new ModuleDef {
modify[Config](modifyingFunction(_))
}
)
}
Mutators are subject to configuration using Activation Axis and will be applied conditionally, if tagged:
import distage.{Activation, Injector, Mode}
def axisIncrement = new ModuleDef {
make[Int].fromValue(1)
modify[Int](_ + 10).tagged(Mode.Test)
modify[Int](_ + 1).tagged(Mode.Prod)
}
Injector().produceRun(axisIncrement, Activation(Mode -> Mode.Test))((currentInt: Int) => currentInt): Int
// res35: Int = 11
Injector().produceRun(axisIncrement, Activation(Mode -> Mode.Prod))((currentInt: Int) => currentInt): Int
// res36: Int = 2
Effect Bindings
Sometimes we want to effectfully create a component, but the resulting component or data does not need to be deallocated. An example might be a global Semaphore
to limit the parallelism of the entire application based on configuration, or a test implementation of some service made with Ref
s.
In these cases we can use .fromEffect
to create a value using an effectful constructor.
Example with a Ref
-based Tagless Final KVStore
:
import distage.{Injector, ModuleDef}
import izumi.functional.bio.{Error2, Primitives2, F}
import zio.{Task, IO}
trait KVStore[F[_, _]] {
def get(key: String): F[NoSuchElementException, String]
def put(key: String, value: String): F[Nothing, Unit]
}
def dummyKVStore[F[+_, +_]: Error2: Primitives2]: F[Nothing, KVStore[F]] = {
for {
ref <- F.mkRef(Map.empty[String, String])
} yield new KVStore[F] {
def put(key: String, value: String): F[Nothing, Unit] = {
ref.update_(_ + (key -> value))
}
def get(key: String): F[NoSuchElementException, String] = {
for {
map <- ref.get
res <- map.get(key) match {
case Some(value) => F.pure(value)
case None => F.fail(new NoSuchElementException(key))
}
} yield res
}
}
}
def kvStoreModule = new ModuleDef {
make[KVStore[IO]].fromEffect(dummyKVStore[IO])
}
val io = Injector[Task]()
.produceRun[String](kvStoreModule) {
(kv: KVStore[IO]) =>
for {
_ <- kv.put("apple", "pie")
res1 <- kv.get("apple")
_ <- kv.put("apple", "ipad")
res2 <- kv.get("apple")
} yield res1 + res2
}
// io: Task[String] = DynamicNoBox(izumi.functional.quasi.QuasiPrimitivesFromBIO.bracket(QuasiIO.scala:339),1,zio.ZIO$Release$$Lambda/0x00007fff8346caf0@72b7cf88)
import izumi.functional.bio.UnsafeRun2
val runtime = UnsafeRun2.createZIO()
// runtime: UnsafeRun2.ZIORunner[Any] = izumi.functional.bio.UnsafeRun2$ZIORunner@e03f7b6
runtime.unsafeRun(io)
// res38: String = pieipad
You must specify your effect type when constructing an Injector
, as in Injector[F]()
, to use effect bindings in the chosen F[_]
type.
You may want to use Lifecycle.LiftF
to convert effect methods with implicit parameters into a class-based form to ensure that implicit parameters are wired from the object graph, not from the surrounding implicit scope. (See Inheritance Helpers)
ZIO Environment bindings
You can inject into ZIO Environment using make[_].fromZEnv
syntax for ZLayer
, ZManaged
, ZIO
or any F[_, _, _]: Local3
:
import zio._
import zio.managed._
import distage.ModuleDef
class Dependency
class X(dependency: Dependency)
def makeX: ZIO[Dependency, Throwable, X] = {
for {
dep <- ZIO.service[Dependency]
_ <- Console.printLine(s"Obtained environment dependency = $dep")
} yield new X(dep)
}
def makeXManaged: ZManaged[Dependency, Throwable, X] = makeX.toManaged
def makeXLayer: ZLayer[Dependency, Throwable, X] = ZLayer.fromZIO(makeX)
def module1 = new ModuleDef {
make[Dependency]
make[X].fromZIOEnv(makeX)
// or
make[X].fromZManagedEnv(makeXManaged)
// or
make[X].fromZLayerEnv(makeXLayer)
}
You can also mix environment and parameter dependencies at the same time in one constructor:
def zioArgEnvCtor(
dependency: Dependency
): RLayer[Console, X] = {
ZLayer.succeed(dependency) ++
ZLayer.environment[Console] >>>
ZLayer.fromZIO(makeX)
}
def module2 = new ModuleDef {
make[Dependency]
make[X].fromZLayerEnv(zioArgEnvCtor _)
}
zio.ZEnvironment
values are derived at compile-time by ZEnvConstructor
macro and can be summoned at need.
Another example:
import distage.{Injector, ModuleDef}
import zio.{Console, UIO, URIO, RIO, ZIO, Ref, Task}
trait Hello {
def hello: UIO[String]
}
trait World {
def world: UIO[String]
}
// Environment forwarders that allow
// using service functions from everywhere
val hello: URIO[Hello, String] = ZIO.serviceWithZIO(_.hello)
// hello: URIO[Hello, String] = Stateful(repl.MdocSession.MdocApp39.hello(basics.md:916),zio.FiberRef$unsafe$$anon$2$$Lambda/0x00007fff834b8000@6ceb0c4c)
val world: URIO[World, String] = ZIO.serviceWithZIO(_.world)
// world: URIO[World, String] = Stateful(repl.MdocSession.MdocApp39.world(basics.md:919),zio.FiberRef$unsafe$$anon$2$$Lambda/0x00007fff834b8000@1785dd9d)
// service implementations
val makeHello = {
for {
_ <- ZIO.acquireRelease(
acquire = Console.printLine("Creating Enterprise Hellower...")
)(release = _ => Console.printLine("Shutting down Enterprise Hellower").orDie)
} yield new Hello {
val hello = ZIO.succeed("Hello")
}
}
// makeHello: ZIO[Any with Any with zio.Scope, java.io.IOException, AnyRef with Hello{val hello: zio.ZIO[Any,Nothing,String]}] = FlatMap(repl.MdocSession.MdocApp39.makeHello(basics.md:924),DynamicNoBox(repl.MdocSession.MdocApp39.makeHello(basics.md:926),1,zio.ZIO$$Lambda/0x00007fff83599bd0@540d1ee7),zio.ZIO$$Lambda/0x00007fff8348f668@2b7a9db4)
val makeWorld = {
for {
counter <- Ref.make(0)
} yield new World {
val world = counter.get.map(c => if (c < 1) "World" else "THE World")
}
}
// makeWorld: ZIO[Any, Nothing, AnyRef with World{val world: zio.ZIO[Any,Nothing,String]}] = FlatMap(repl.MdocSession.MdocApp39.makeWorld(basics.md:935),Sync(repl.MdocSession.MdocApp39.makeWorld(basics.md:935),zio.Ref$$$Lambda/0x00007fff83593390@72e71752),zio.ZIO$$Lambda/0x00007fff8348f668@d0fb387)
// the main function
val turboFunctionalHelloWorld: RIO[Hello with World, Unit] = {
for {
hello <- hello
world <- world
_ <- Console.print(s"$hello $world")
} yield ()
}
// turboFunctionalHelloWorld: RIO[Hello with World, Unit] = FlatMap(repl.MdocSession.MdocApp39.turboFunctionalHelloWorld(basics.md:944),Stateful(repl.MdocSession.MdocApp39.hello(basics.md:916),zio.FiberRef$unsafe$$anon$2$$Lambda/0x00007fff834b8000@6ceb0c4c),<function1>)
def module = new ModuleDef {
make[Hello].fromZIOEnv(makeHello)
make[World].fromZIOEnv(makeWorld)
make[Unit].fromZIOEnv(turboFunctionalHelloWorld)
}
val main = Injector[Task]()
.produceRun[Unit](module)((_: Unit) => ZIO.unit)
// main: Task[Unit] = DynamicNoBox(izumi.functional.quasi.QuasiPrimitivesFromBIO.bracket(QuasiIO.scala:339),1,zio.ZIO$Release$$Lambda/0x00007fff8346caf0@9b4d847)
import izumi.functional.bio.UnsafeRun2
val runtime = UnsafeRun2.createZIO()
// runtime: UnsafeRun2.ZIORunner[Any] = izumi.functional.bio.UnsafeRun2$ZIORunner@7dbf6e72
runtime.unsafeRun(main)
// Creating Enterprise Hellower...
// Hello WorldShutting down Enterprise Hellower
Converting ZIO environment dependencies to parameters
Any ZIO Service that requires an environment can be turned into a service without an environment dependency by providing the dependency in each method using .provide
.
This pattern can be generalized by implementing an instance of cats.Contravariant
(or cats.tagless.FunctorK
) for your services and using it to turn environment dependencies into constructor parameters.
In that way ZIO Environment can be used uniformly for declaration of dependencies, but the dependencies used inside the service do not leak to other services calling it. See: https://gitter.im/ZIO/Core?at=5dbb06a86570b076740f6db2
Example:
import distage.{Injector, ModuleDef, Functoid, Tag, TagK, ZEnvConstructor}
import zio.{URIO, ZIO, ZEnvironment}
trait Dependee[-R] {
def x(y: String): URIO[R, Int]
}
trait Depender[-R] {
def y: URIO[R, String]
}
trait ContravariantService[M[_]] {
def contramapZEnv[A, B](s: M[A])(f: ZEnvironment[B] => ZEnvironment[A]): M[B]
}
implicit val contra1: ContravariantService[Dependee] = new ContravariantService[Dependee] {
def contramapZEnv[A, B](fa: Dependee[A])(f: ZEnvironment[B] => ZEnvironment[A]): Dependee[B] = new Dependee[B] { def x(y: String) = fa.x(y).provideSomeEnvironment(f) }
}
// contra1: ContravariantService[Dependee] = repl.MdocSession$MdocApp41$$anon$28@37860912
implicit val contra2: ContravariantService[Depender] = new ContravariantService[Depender] {
def contramapZEnv[A, B](fa: Depender[A])(f: ZEnvironment[B] => ZEnvironment[A]): Depender[B] = new Depender[B] { def y = fa.y.provideSomeEnvironment(f) }
}
// contra2: ContravariantService[Depender] = repl.MdocSession$MdocApp41$$anon$30@5c63c2b1
type DependeeR = Dependee[Any]
type DependerR = Depender[Any]
object dependee extends Dependee[DependeeR] {
def x(y: String) = ZIO.serviceWithZIO(_.x(y))
}
object depender extends Depender[DependerR] {
def y = ZIO.serviceWithZIO(_.y)
}
// cycle
object dependerImpl extends Depender[DependeeR] {
def y: URIO[DependeeR, String] = dependee.x("hello").map(_.toString)
}
object dependeeImpl extends Dependee[DependerR] {
def x(y: String): URIO[DependerR, Int] = {
if (y == "hello") ZIO.succeed(5)
else depender.y.map(y.length + _.length)
}
}
/** Fulfill the environment dependencies of a service from the object graph */
def fullfill[R: Tag: ZEnvConstructor, M[_]: TagK: ContravariantService](service: M[R]): Functoid[M[Any]] = {
ZEnvConstructor[R]
.map(zenv => implicitly[ContravariantService[M]].contramapZEnv(service)(_ => zenv))
}
def module = new ModuleDef {
make[Depender[Any]].from(fullfill(dependerImpl))
make[Dependee[Any]].from(fullfill(dependeeImpl))
}
import izumi.functional.bio.UnsafeRun2
val runtime = UnsafeRun2.createZIO()
// runtime: UnsafeRun2.ZIORunner[Any] = izumi.functional.bio.UnsafeRun2$ZIORunner@694936e2
runtime.unsafeRun {
Injector()
.produceRun(module) {
ZEnvConstructor[DependeeR].map {
(for {
r <- dependee.x("zxc")
_ <- ZIO.attempt(println(s"result: $r"))
} yield ()).provideEnvironment(_)
}
}
}
// result: 4
Auto-Traits
distage can instantiate traits and structural types.
Use makeTrait[X]
or make[X].fromTrait[Y]
to wire traits, abstract classes or a structural types.
All unimplemented fields in a trait, or a refinement are filled in from the object graph. Trait implementations are derived at compile-time by TraitConstructor
macro and can be summoned at need.
Example:
import distage.{ModuleDef, Id, Injector}
trait Trait1 {
def a: Int @Id("a")
}
trait Trait2 {
def b: Int @Id("b")
}
/** All methods in this trait are implemented,
* so a constructor for it will be generated
* even though it's not a class */
trait Pluser {
def plus(a: Int, b: Int) = a + b
}
trait PlusedInt {
def result(): Int
}
object PlusedInt {
/**
* Besides the dependency on `Pluser`,
* this class defines 2 more dependencies
* to be injected from the object graph:
*
* `def a: Int @Id("a")` and
* `def b: Int @Id("b")`
*
* When an abstract type is declared as an implementation,
* its no-argument abstract defs & vals are considered as
* dependency parameters by TraitConstructor. (empty-parens and
* parameterized methods are not considered parameters)
*
* Here, using an abstract class directly as an implementation
* lets us avoid writing a lengthier constructor, like this one:
*
* {{{
* final class Impl(
* pluser: Pluser,
* override val a: Int @Id("a"),
* override val b: Int @Id("b"),
* ) extends PlusedInt with Trait1 with Trait2
* }}}
*/
abstract class Impl(
pluser: Pluser
) extends PlusedInt
with Trait1
with Trait2 {
override def result(): Int = {
pluser.plus(a, b)
}
}
}
def module = new ModuleDef {
make[Int].named("a").from(1)
make[Int].named("b").from(2)
makeTrait[Pluser]
make[PlusedInt].fromTrait[PlusedInt.Impl]
}
Injector().produceRun(module) {
(plusedInt: PlusedInt) =>
plusedInt.result()
}
// res44: distage.package.Identity[Int] = 3
@impl annotation
Abstract classes or traits without obvious concrete subclasses may hinder the readability of a codebase, to mitigate that you may use an optional @impl
documenting annotation to aid the reader in understanding your intention.
import distage.impl
@impl abstract class Impl(
pluser: Pluser
) extends PlusedInt with Trait1 with Trait2 {
override def result(): Int = {
pluser.plus(a, b)
}
}
Avoiding constructors even further
When overriding behavior of a class, you may avoid writing a repeat of its constructor in your subclass by inheriting a trait from it instead. Example:
/**
* Note how we avoid writing a call to the super-constructor
* of `PlusedInt.Impl`, such as:
*
* {{{
* abstract class OverridenPlusedIntImpl(
* pluser: Pluser
* ) extends PlusedInt.Impl(pluser)
* }}}
*
* Which would be unavoidable with class-to-class inheritance.
* Using trait-to-class inheritance we avoid writing any boilerplate
* besides the overrides we want to apply to the class.
*/
@impl trait OverridenPlusedIntImpl extends PlusedInt.Impl {
override def result(): Int = {
super.result() * 10
}
}
Injector().produceRun(module overriddenBy new ModuleDef {
make[PlusedInt].fromTrait[OverridenPlusedIntImpl]
}) {
(plusedInt: PlusedInt) =>
plusedInt.result()
}
// res45: distage.package.Identity[Int] = 30
Auto-Factories
distage
can derive ‘factory’ implementations from suitable traits using makeFactory
method. This feature is especially useful with Akka
. All unimplemented methods with parameters in a trait will be filled by factory methods:
Given a class ActorFactory
:
import distage.ModuleDef
import java.util.UUID
class SessionStorage
class UserActor(sessionId: UUID, sessionStorage: SessionStorage)
trait ActorFactory {
// UserActor will be created as follows:
// sessionId argument is provided by the user
// sessionStorage argument is wired from the object graph
def createActor(sessionId: UUID): UserActor
}
And a binding of ActorFactory
without an implementation.
class ActorModule extends ModuleDef {
makeFactory[ActorFactory]
}
distage
will derive and bind the following implementation for ActorFactory
:
class ActorFactoryImpl(sessionStorage: SessionStorage) extends ActorFactory {
override def createActor(sessionId: UUID): UserActor = {
new UserActor(sessionId, sessionStorage)
}
}
Note that ordinary function types conform to distage’s definition of a ‘factory’, since they are just traits with an unimplemented method. Sometimes declaring a separate named factory trait isn’t worth it, in these cases you can use makeFactory
to generate ordinary function types:
object UserActor {
type Factory = UUID => UserActor
}
class ActorFunctionModule extends ModuleDef {
makeFactory[UserActor.Factory]
}
You can use this feature to concisely provide non-Singleton semantics for some of your components.
Factory implementations are derived at compile-time by FactoryConstructor
macro and can be summoned at need.
Since distage
version 1.1.0
you have to bind factories explicitly using makeFactory
and fromFactory
methods, not implicitly via make
; parameterless methods in factories now produce new instances instead of summoning a dependency.
@With annotation
@With
annotation can be used to specify the implementation class, to avoid leaking the implementation type in factory method result:
import distage.{Injector, ModuleDef, With}
trait Actor {
def receive(msg: Any): Unit
}
object Actor {
trait Factory {
def newActor(id: String): Actor @With[Actor.Impl]
}
final class Impl(id: String, config: Actor.Configuration) extends Actor {
def receive(msg: Any): Unit = {
val response = s"Actor `$id` received a message: $msg"
println(if (config.allCaps) response.toUpperCase else response)
}
}
final case class Configuration(allCaps: Boolean)
}
def factoryModule = new ModuleDef {
makeFactory[Actor.Factory]
make[Actor.Configuration].from(Actor.Configuration(allCaps = false))
}
Injector()
.produceGet[Actor.Factory](factoryModule)
.use(_.newActor("Martin Odersky").receive("ping"))
// Actor `Martin Odersky` received a message: ping
// res48: distage.package.Identity[Unit] = ()
Subcontexts
Sometimes multiple components depend on the same piece of data that appears locally, after all the components were already wired. This data may need to be passed around repeatedly, possibly across the entire application. To do this, we may have to add an argument to most methods of an application, or have to use a Reader monad everywhere.
For example, we could be adding distributed tracing to our application - after getting a RequestId from a request, we may need to carry it everywhere to add it to logs and metrics.
Ideally, instead of adding the same argument to our methods, we’d want to just move that argument data out to the class constructor - passing the argument just once during the construction of a class. However, we’d lose the ability to automatically wire our objects, since we can only get a RequestId from a request, it’s not available when we initially wire our object graph.
Since 1.2.0 this problem is addressed in distage using Subcontext
s - using them we can define a wireable sub-graph of our components that depend on local data unavailable during wiring, but that we can then finish wiring once we pass them the data.
Starting with a graph that has no local dependencies:
import izumi.functional.bio.IO2
import distage.{ModuleDef, Subcontext, TagKK}
class PetStoreBusinessLogic[F[+_, +_]] {
// requestId is a method parameter
def buyPetLogic(requestId: RequestId, petId: PetId, payment: Int): F[Throwable, Pet] = ???
}
def module1[F[+_, +_]: TagKK] = new ModuleDef {
make[PetStoreAPIHandler[F]]
make[PetStoreRepository[F]]
make[PetStoreBusinessLogic[F]]
}
class PetStoreAPIHandler[F[+_, +_]: IO2](
petStoreBusinessLogic: PetStoreBusinessLogic[F]
) {
def buyPet(petId: PetId, payment: Int): F[Throwable, Pet] = {
petStoreBusinessLogic.buyPetLogic(RequestId(), petId, payment)
}
}
We use makeSubcontext
to delineate a portion of the graph that requires a RequestId
to be wired:
class PetStoreBusinessLogic[F[+_, +_]](
// requestId is a now a class parameter
requestId: RequestId
) {
def buyPetLogic(petId: PetId, payment: Int): F[Throwable, Pet] = ???
}
def module2[F[+_, +_] : TagKK] = new ModuleDef {
make[PetStoreAPIHandler[F]]
makeSubcontext[PetStoreBusinessLogic[F]]
.withSubmodule(new ModuleDef {
make[PetStoreRepository[F]]
make[PetStoreBusinessLogic[F]]
})
.localDependency[RequestId]
}
class PetStoreAPIHandler[F[+_, +_]: IO2: TagKK](
petStoreBusinessLogic: Subcontext[PetStoreBusinessLogic[F]]
) {
def buyPet(petId: PetId, payment: Int): F[Throwable, Pet] = {
// we have to pass the parameter and create the component now, since it's not already wired.
petStoreBusinessLogic
.provide[RequestId](RequestId())
.produceRun {
_.buyPetLogic(petId, payment)
}
}
}
We managed to move RequestId from a method parameter that polluted every method signature, to a class parameter, that we pass to the subgraph just once - when the RequestId is generated.
Full example:
import distage.{Injector, Lifecycle, ModuleDef, Subcontext, TagKK}
import izumi.functional.bio.{Error2, F, IO2, Monad2, Primitives2}
import izumi.functional.bio.data.Morphism1
import logstage.{IzLogger, LogIO2}
import izumi.logstage.distage.LogIO2Module
import java.util.UUID
final case class PetId(petId: UUID)
final case class RequestId(requestId: UUID)
sealed trait TransactionFailure
object TransactionFailure {
case object NoSuchPet extends TransactionFailure
case object InsufficientFunds extends TransactionFailure
}
final case class Pet(name: String, species: String, price: Int)
final class PetStoreAPIHandler[F[+_, +_]: IO2: TagKK](
petStoreBusinessLogic: Subcontext[PetStoreBusinessLogic[F]]
) {
def buyPet(petId: PetId, payment: Int): F[TransactionFailure, Pet] = {
for {
requestId <- F.sync(RequestId(UUID.randomUUID()))
pet <- petStoreBusinessLogic
.provide[RequestId](requestId)
.produce[F[Throwable, _]]()
.mapK[F[Throwable, _], F[TransactionFailure, _]](Morphism1(_.orTerminate))
.use {
component =>
component.buyPetLogic(petId, payment)
}
} yield pet
}
}
final class PetStoreBusinessLogic[F[+_, +_]: Error2](
requestId: RequestId,
petStoreReposistory: PetStoreReposistory[F],
log: LogIO2[F],
) {
private val contextLog = log.withCustomContext("requestId" -> requestId)
def buyPetLogic(petId: PetId, payment: Int): F[TransactionFailure, Pet] = {
for {
pet <- petStoreReposistory.findPet(petId).fromOption(TransactionFailure.NoSuchPet)
_ <- if (payment < pet.price) {
contextLog.error(s"Insufficient $payment, couldn't afford ${pet.price}") *>
F.fail(TransactionFailure.InsufficientFunds)
} else {
for {
result <- petStoreReposistory.removePet(petId)
_ <- F.when(!result)(F.fail(TransactionFailure.NoSuchPet))
_ <- contextLog.info(s"Successfully bought $pet with $petId for $payment! ${payment - pet.price -> "overpaid"}")
} yield ()
}
} yield pet
}
}
trait PetStoreReposistory[F[+_, +_]] {
def findPet(petId: PetId): F[Nothing, Option[Pet]]
def removePet(petId: PetId): F[Nothing, Boolean]
}
object PetStoreReposistory {
final class Impl[F[+_, +_]: Monad2: Primitives2](
requestId: RequestId,
log: LogIO2[F],
) extends Lifecycle.LiftF[F[Nothing, _], PetStoreReposistory[F]](for {
state <- F.mkRef(Pets.builtinPetMap)
} yield new PetStoreReposistory[F] {
private val contextLog = log("requestId" -> requestId)
override def findPet(petId: PetId): F[Nothing, Option[Pet]] = {
for {
_ <- contextLog.info(s"Looking up $petId")
maybePet <- state.get.map(_.get(petId))
_ <- contextLog.info(s"Got $maybePet")
} yield maybePet
}
override def removePet(petId: PetId): F[Nothing, Boolean] = {
for {
success <- state.modify(s => (s.contains(petId), s - petId))
_ <- contextLog.info(s"Tried to remove $petId, $success")
} yield success
}
})
}
object Pets {
val arnoldId = PetId(UUID.randomUUID())
val buckId = PetId(UUID.randomUUID())
val chipId = PetId(UUID.randomUUID())
val derryId = PetId(UUID.randomUUID())
val emmyId = PetId(UUID.randomUUID())
val builtinPetMap = Map[PetId, Pet](
arnoldId -> Pet("Arnold", "Dog", 99),
buckId -> Pet("Buck", "Rabbit", 60),
chipId -> Pet("Chip", "Cat", 75),
derryId -> Pet("Derry", "Dog", 250),
emmyId -> Pet("Emmy", "Guinea Pig", 20)
)
}
object Module extends ModuleDef {
include(module[zio.IO])
def module[F[+_, +_]: TagKK] = new ModuleDef {
make[PetStoreAPIHandler[F]]
make[IzLogger].from(IzLogger())
include(LogIO2Module[F]())
makeSubcontext[PetStoreBusinessLogic[F]]
.withSubmodule(new ModuleDef {
make[PetStoreReposistory[F]].fromResource[PetStoreReposistory.Impl[F]]
make[PetStoreBusinessLogic[F]]
})
.localDependency[RequestId]
}
}
import izumi.functional.bio.UnsafeRun2
val runner = UnsafeRun2.createZIO()
// runner: UnsafeRun2.ZIORunner[Any] = izumi.functional.bio.UnsafeRun2$ZIORunner@5f82c4b8
val result = runner.unsafeRun {
Injector[zio.Task]()
.produceRun(Module) {
(p: PetStoreAPIHandler[zio.IO]) =>
p.buyPet(Pets.arnoldId, 100).attempt
}
}
// I 2024-11-19T15:02:55.117 (basics.md:1493) …PetStoreReposistory.findPet [637:Thread-198] requestId=RequestId(6e36f603-9928-40aa-8510-8b97f2765697) Looking up pet_id=PetId(59e491ad-d290-4b6e-a2c9-4cdca3b43aaa)
// I 2024-11-19T15:02:55.126 (basics.md:1495) …P.I.1.1.P.findPet.1493.1494 [637:Thread-198] requestId=RequestId(6e36f603-9928-40aa-8510-8b97f2765697) Got maybe_pet=Some(Pet(Arnold,Dog,99))
// I 2024-11-19T15:02:55.132 (basics.md:1502) …eReposistory.removePet.1501 [637:Thread-198] requestId=RequestId(6e36f603-9928-40aa-8510-8b97f2765697) Tried to remove pet_id=PetId(59e491ad-d290-4b6e-a2c9-4cdca3b43aaa), success=true
// I 2024-11-19T15:02:55.136 (basics.md:1468) r.M.M.P.b.1460.1466.1467 [637:Thread-198] requestId=RequestId(6e36f603-9928-40aa-8510-8b97f2765697) Successfully bought pet=Pet(Arnold,Dog,99) with pet_id=PetId(59e491ad-d290-4b6e-a2c9-4cdca3b43aaa) for payment=100! overpaid=1
// result: Either[TransactionFailure, Pet] = Right(
// value = Pet(name = "Arnold", species = "Dog", price = 99)
// )
Using subcontexts is more efficient than nesting Injectors manually, since subcontexts are planned ahead of time - there’s no planning step for subcontexts, only execution step.
Note: When your subcontext’s submodule only contains one binding, you may be able to achieve the same result using an Auto-Factory instead.
Tagless Final Style
Tagless Final is one of the popular patterns for structuring purely-functional applications.
Brief introduction to tagless final:
Advantages of distage
as a driver for TF compared to implicits:
- easy explicit overrides
- easy effectful instantiation and resource management
- extremely easy & scalable test context setup due to the above
- multiple different implementations for a type using disambiguation by
@Id
For example, let’s take freestyle
’s tagless example and make it better by replacing dependencies on global import
ed implementations with explicit modules.
First, the program we want to write:
import cats.Monad
import cats.effect.{Sync, IO}
import cats.syntax.all._
import distage.{Roots, ModuleDef, Injector, Tag, TagK, TagKK}
trait Validation[F[_]] {
def minSize(s: String, n: Int): F[Boolean]
def hasNumber(s: String): F[Boolean]
}
object Validation {
def apply[F[_]: Validation]: Validation[F] = implicitly
}
trait Interaction[F[_]] {
def tell(msg: String): F[Unit]
def ask(prompt: String): F[String]
}
object Interaction {
def apply[F[_]: Interaction]: Interaction[F] = implicitly
}
class TaglessProgram[F[_]: Monad: Validation: Interaction] {
def program: F[Unit] = for {
userInput <- Interaction[F].ask("Give me something with at least 3 chars and a number on it")
valid <- (Validation[F].minSize(userInput, 3), Validation[F].hasNumber(userInput)).mapN(_ && _)
_ <- if (valid) Interaction[F].tell("awesomesauce!")
else Interaction[F].tell(s"$userInput is not valid")
} yield ()
}
def ProgramModule[F[_]: TagK: Monad] = new ModuleDef {
make[TaglessProgram[F]]
}
TagK
is distage
’s analogue of TypeTag
for higher-kinded types such as F[_]
, it allows preserving type-information at runtime for type parameters. You’ll need to add a TagK
context bound to create a module parameterized by an abstract F[_]
. To parameterize by non-higher-kinded types, use just Tag
.
Now the interpreters for Validation
and Interaction
:
final class SyncValidation[F[_]](implicit F: Sync[F]) extends Validation[F] {
def minSize(s: String, n: Int): F[Boolean] = F.delay(s.size >= n)
def hasNumber(s: String): F[Boolean] = F.delay(s.exists(c => "0123456789".contains(c)))
}
final class SyncInteraction[F[_]](implicit F: Sync[F]) extends Interaction[F] {
def tell(s: String): F[Unit] = F.delay(println(s))
def ask(s: String): F[String] = F.delay("This could have been user input 1")
}
def SyncInterpreters[F[_]: TagK: Sync] = {
new ModuleDef {
make[Validation[F]].from[SyncValidation[F]]
make[Interaction[F]].from[SyncInteraction[F]]
}
}
// combine all modules
def SyncProgram[F[_]: TagK: Sync] = ProgramModule[F] ++ SyncInterpreters[F]
// create object graph Lifecycle
val objectsLifecycle = Injector[IO]().produce(SyncProgram[IO], Roots.Everything)
// objectsLifecycle: izumi.distage.model.definition.package.Lifecycle[IO, izumi.distage.model.Locator] = izumi.functional.lifecycle.LifecycleMethodImpls$$anon$2@66f6009e
// run
import cats.effect.unsafe.implicits.global
objectsLifecycle.use(_.get[TaglessProgram[IO]].program).unsafeRunSync()
// awesomesauce!
Effect-type polymorphism
The program module is polymorphic over effect type. It can be instantiated by a different effect:
import zio.interop.catz._
import zio.Task
val ZIOProgram = ProgramModule[Task] ++ SyncInterpreters[Task]
// ZIOProgram: izumi.distage.model.definition.Module =
// make[{type.repl.MdocSession.MdocApp51.Validation[zio.ZIO[-scala.Any,+java.lang.Throwable,+_]]}].from(call(π:Constructor(cats.effect.kernel.Sync[=λ %0 → zio.ZIO[-scala.Any,+java.lang.Throwable,+0]]): repl.MdocSession::MdocApp51::SyncValidation[=λ %0 → zio.ZIO[-scala.Any,+java.lang.Throwable,+0]])) (BindingOrigin((basics.md:1633)))
// make[{type.repl.MdocSession.MdocApp51.Interaction[zio.ZIO[-scala.Any,+java.lang.Throwable,+_]]}].from(call(π:Constructor(cats.effect.kernel.Sync[=λ %0 → zio.ZIO[-scala.Any,+java.lang.Throwable,+0]]): repl.MdocSession::MdocApp51::SyncInteraction[=λ %0 → zio.ZIO[-scala.Any,+java.lang.Throwable,+0]])) (BindingOrigin((basics.md:1634)))
// make[{type.repl.MdocSession.MdocApp51.TaglessProgram[zio.ZIO[-scala.Any,+java.lang.Throwable,+_]]}].from(call(π:Constructor(cats.Monad[=λ %0 → zio.ZIO[-scala.Any,+java.lang.Throwable,+0]], repl.MdocSession::MdocApp51::Validation[=λ %0 → zio.ZIO[-scala.Any,+java.lang.Throwable,+0]], repl.MdocSession::MdocApp51::Interaction[=λ %0 → zio.ZIO[-scala.Any,+java.lang.Throwable,+0]]): repl.MdocSession::MdocApp51::TaglessProgram[=λ %0 → zio.ZIO[-scala.Any,+java.lang.Throwable,+0]])) (BindingOrigin((basics.md:1612)))
We may even choose different interpreters at runtime:
import zio.Console
import distage.Activation
object RealInteractionZIO extends Interaction[Task] {
def tell(s: String): Task[Unit] = Console.printLine(s)
def ask(s: String): Task[String] = Console.printLine(s) *> Console.readLine
}
def RealInterpretersZIO = {
SyncInterpreters[Task] overriddenBy new ModuleDef {
make[Interaction[Task]].from(RealInteractionZIO)
}
}
def chooseInterpreters(isDummy: Boolean) = {
val interpreters = if (isDummy) SyncInterpreters[Task]
else RealInterpretersZIO
def module = ProgramModule[Task] ++ interpreters
Injector[Task]()
.produceGet[TaglessProgram[Task]](module, Activation.empty)
}
// execute
chooseInterpreters(true)
// res53: izumi.distage.model.definition.package.Lifecycle[Task, TaglessProgram[Task]] = izumi.functional.lifecycle.LifecycleMethodImpls$$anon$1@5bb23367
Kind polymorphism
Modules can be polymorphic over arbitrary kinds - use TagKK
to abstract over bifunctors:
class BifunctorIOModule[F[_, _]: TagKK] extends ModuleDef
Or use Tag.auto.T
to abstract over any kind:
class MonadTransformerModule[F[_[_], _]: Tag.auto.T] extends ModuleDef
class EldritchModule[F[+_, -_[_, _], _[_[_, _], _], _]: Tag.auto.T] extends ModuleDef
consult izumi.reflect.HKTag docs for more details.
Cats & ZIO Integration
Cats & ZIO instances and syntax are available automatically in distage-core
, without wildcard imports, if your project depends on cats-core
, cats-effect
or zio
. However, distage won’t bring in cats
or zio
as dependencies if you don’t already depend on them. (see No More Orphans blog post for details on how that works)
Cats Resource & ZIO ZManaged Bindings also work out of the box without any magic imports.
All relevant typeclass instances for chosen effect type, such as ConcurrentEffect[F]
, are included by default (overridable by user bindings)