distage-framework

Roles

A “Role” is an entrypoint for a specific application hosted in a larger software suite. Bundling multiple roles in a single .jar file can simplify deployment and operations.

distage-framework module contains the distage Role API:

libraryDependencies += "io.7mind.izumi" %% "distage-framework" % "0.10.16"

With default RoleAppLauncherImpl, roles to launch are specified on the command-line: ./launcher role1 role2 role3. Only the components required by the specified roles will be created, everything else will be pruned. (see: GC)

Two roles are bundled by default: Help and ConfigWriter.

Further reading: Roles: a viable alternative to Microservices

Typesafe Config

distage-extension-config library allows summoning case classes and sealed traits from typesafe-config configuration

To use it, add distage-extension-config library:

libraryDependencies += "io.7mind.izumi" %% "distage-extension-config" % "0.10.16"

Use helper functions in ConfigModuleDef to parse the Typesafe Config instance bound to AppConfig into case classes:

import distage.{DIKey, Roots, ModuleDef, Id, Injector}
import distage.config.{AppConfig, ConfigModuleDef}
import com.typesafe.config.ConfigFactory

final case class Conf(name: String, age: Int)

final case class OtherConf(other: Boolean)

final class ConfigPrinter(conf: Conf, otherConf: OtherConf @Id("other")) {
  def print() = {
    println(s"name: ${conf.name}, age: ${conf.age}, other: ${otherConf.other}")
  }
}

val module = new ConfigModuleDef {
  make[ConfigPrinter]

  // declare paths to parse
  makeConfig[Conf]("conf")
  makeConfig[OtherConf]("conf").named("other")
 
  // add config instance
  make[AppConfig].from(AppConfig(ConfigFactory.parseString(
    """conf {
      |  name = "John"
      |  age = 33
      |  other = true
      |}""".stripMargin
  )))
}
// module: AnyRef with ConfigModuleDef = 
// make[{type.repl.MdocSession::repl.MdocSession.App0::repl.MdocSession.App0.OtherConf@other}].from(call(izumi.distage.config.ConfigModuleDef$$$Lambda$20331/822049352@6666a6a6(izumi.distage.config.model.AppConfig): repl.MdocSession::repl.MdocSession.App0::repl.MdocSession.App0.OtherConf)).tagged(Set(ConfTag(conf))) ((distage-framework.md:38))
// make[{type.repl.MdocSession::repl.MdocSession.App0::repl.MdocSession.App0.ConfigPrinter}].from(call(Class(repl.MdocSession::repl.MdocSession.App0::repl.MdocSession.App0.Conf, repl.MdocSession::repl.MdocSession.App0::repl.MdocSession.App0.OtherConf): repl.MdocSession::repl.MdocSession.App0::repl.MdocSession.App0.ConfigPrinter)) ((distage-framework.md:34))
// make[{type.izumi.distage.config.model.AppConfig}].from(call(izumi.distage.model.definition.dsl.ModuleDefDSL$MakeDSLBase$$Lambda$19802/1248558164@50b7248c(): izumi.distage.config.model.AppConfig)) ((distage-framework.md:41))
// make[{type.repl.MdocSession::repl.MdocSession.App0::repl.MdocSession.App0.Conf}].from(call(izumi.distage.config.ConfigModuleDef$$$Lambda$20331/822049352@6892ff09(izumi.distage.config.model.AppConfig): repl.MdocSession::repl.MdocSession.App0::repl.MdocSession.App0.Conf)).tagged(Set(ConfTag(conf))) ((distage-framework.md:37))

Injector().produceRun(module) {
  configPrinter: ConfigPrinter =>
    configPrinter.print()
} 
// name: John, age: 33, other: true

Automatic derivation of config codecs is based on pureconfig-magnolia. Pureconfig codecs for a type will be used if they exist.

You don’t have to explicitly make[AppConfig] in distage-testkit’s tests and in distage-framework’s Roles, unless you want to override default behavior. By default, tests and roles will try to read the configurations from resources with the following names, in order:

- $roleName.conf
- $roleName-reference.conf
- $roleName-reference-dev.conf
- application.conf
- application-reference.conf
- application-reference-dev.conf
- common.conf
- common-reference.conf
- common-reference-dev.conf

Where distage-testkit uses TestConfig#testBaseName instead of roleName

Plugins

distage-extension-plugins module adds classpath discovery for modules that inherit a marker trait PluginBase. Plugins enable extreme late-binding; e.g. they allow a program to extend itself at launch time with new Plugin classes on the classpath. Plugins are compatible with compile-time checks as long as they’re defined in a separate module.

To use plugins, first add the distage-extension-plugins library:

libraryDependencies += "io.7mind.izumi" %% "distage-extension-plugins" % "0.10.16"

Create a module extending the PluginDef trait instead of ModuleDef:

// package com.example.petstore

import distage._
import distage.plugins._

object PetStorePlugin extends PluginDef {
  make[PetRepository]
  make[PetStoreService]
  make[PetStoreController]
}

Collect all PluginDefs in a package:

val pluginConfig = PluginConfig.cached(
  packagesEnabled = Seq("com.example.petstore") // packages to scan
)
// pluginConfig: PluginConfig = PluginConfig(List(com.example.petstore),List(),true,false,List(),List())

val appModules = PluginLoader().load(pluginConfig)
// appModules: Seq[PluginBase] = List(
// make[{type.com.example.petstore.PetRepository}].from(call(Class(): com.example.petstore.PetRepository)) ((PetStorePlugin.scala:6))
// make[{type.com.example.petstore.PetStoreController}].from(call(Class(): com.example.petstore.PetStoreController)) ((PetStorePlugin.scala:8))
// make[{type.com.example.petstore.PetStoreService}].from(call(Class(): com.example.petstore.PetStoreService)) ((PetStorePlugin.scala:7)))

Execute collected modules as usual:

// combine all modules into one

val appModule = appModules.merge
// appModule: PluginBase = 
// make[{type.com.example.petstore.PetRepository}].from(call(Class(): com.example.petstore.PetRepository)) ((PetStorePlugin.scala:6))
// make[{type.com.example.petstore.PetStoreController}].from(call(Class(): com.example.petstore.PetStoreController)) ((PetStorePlugin.scala:8))
// make[{type.com.example.petstore.PetStoreService}].from(call(Class(): com.example.petstore.PetStoreService)) ((PetStorePlugin.scala:7))

// launch

Injector()
  .produceGet[PetStoreController](appModule)
  .use(_.run())
// PetStoreController: running!
// res4: izumi.fundamentals.platform.functional.package.Identity[Unit] = ()

Compile-time checks

An experimental compile-time verification API is available in the distage-framework module.

To use it add distage-framework library:

libraryDependencies += "io.7mind.izumi" %% "distage-framework" % "0.10.16"

Only plugins defined in a different module can be checked at compile-time, test scope counts as a different module.

Example:

In main scope:

// package com.example

import distage.DIKey
import distage.StandardAxis.Env
import distage.config.ConfigModuleDef
import distage.plugins.PluginDef
import izumi.distage.staticinjector.plugins.ModuleRequirements

final case class HostPort(host: String, port: Int)

final case class Config(hostPort: HostPort)

final class Service(conf: Config, otherService: OtherService)
final class OtherService

// error: OtherService is not bound here, even though Service depends on it
final class AppPlugin extends PluginDef with ConfigModuleDef {
  tag(Env.Prod)
  
  make[Service]
  makeConfig[Config]("config")
}

// Declare OtherService as an external dependency
final class AppRequirements extends ModuleRequirements(
  // If we remove this line, compilation will rightfully break
  Set(DIKey[OtherService])
)

In config:

// src/main/resources/application.conf
config {
  host = localhost
  port = 8080
}

In test scope:

// package com.example.test

import com.example._
import org.scalatest.wordspec.AnyWordSpec
import izumi.distage.staticinjector.plugins.StaticPluginChecker

final class AppPluginTest extends AnyWordSpec {
  "App plugin will work (if OtherService is provided later)" in {
    StaticPluginChecker.checkWithConfig[AppPlugin, AppRequirements]("env:prod", ".*.application.conf")   
  }
}

checkWithConfig will run at compile-time whenever AppPluginTest is recompiled.

Note: Since version 0.10.0, configuration files are no longer checked for correctness by the compile-time checker, see: https://github.com/7mind/izumi/issues/763