Contents

Autowire - an overview

Mateusz Borek

17 Mar 2022.8 minutes read

Autowire - an overview webp image

Recently, we released the first version of a new Macwire feature called autowire. It derives from the well known wire function, but introduces a few really interesting changes, namely:

  • recursive wiring,
  • explicit list of instances that are used in the wire process,
  • integration with cats-effect library.

In this article, I’ll try to describe this feature in-depth and also walk you through some examples.

What is MacWire?

If you’re not yet familiar with MacWire: it’s a lightweight and non-intrusive Scala dependency injection library, and in many cases a replacement for DI containers.

In case you're interested in DI in Scala, I highly recommend you this guide.

The goal of autowire

autowire is a macro-based functionality that builds an instance of the target type based on a list of available dependencies. When applying a creator of the target class, it’s not limited to the defined dependencies, but it’s also able to create intermediate dependencies. It makes it possible to get rid of a great part of boilerplate that is required when we’re using wire. Moreover, autowire is able to inject instances that are wrapped into common cats containers: IO[_] and Resource[IO, _], which makes it quite useful in the cats stack.

A simple example

There are quite a few test cases defined in the integration tests module, but let’s start with a simple one to get a general overview of this feature.

class A()
class B()
class C(a: A, b: B)
case class D(c: C)

object Test {

  val ioA: IO[A] = IO { new A() }
  val resourceB: Resource[IO, B] = Resource.eval(IO { new B() })
  def makeC(a: A, b: B): Resource[IO, C] = Resource.eval(IO{ new C(a, b) })

  val theD = autowire[D](ioA, resourceB, makeC _)

}

In this example, we use three ways to provide an instance to autowire:

  • an effect,
  • a resource,
  • a result of a factory method.

Since autowire always returns result instances wrapped in Resource[IO, *], ioA under the hood is lifted with Resource.eval. The result - theD resource is a composition of input resources and the underlying instance is created with the given dependencies, so autowire generates something similar to:

val theD = for {
    fresh$macro$1 <- Resource.eval[IO, A](ioA)
    fresh$macro$2 <- resourceB
    fresh$macro$3 <- makeC(fresh$macro$1, fresh$macro$2)
    fresh$macro$4 <- new D(fresh$macro$3)
} yield fresh$macro$4

It’s not exactly the generated code, I simplified it for the sake of readability.

Macwire at compile-time performs an in-place sort of dependencies to make the wiring process possible. “In place” in this context means that only dependencies that are required by preceding creators are moved. It may sound a little bit mysterious but let’s consider another simple example to make it clear.

case class A()
class B(i: Int)
class C(b: B, s: String)
case class D(c: C)

object Test {
  def makeB(a: A): Resource[IO, B] = Resource.eval(IO{ new B(0) })
  def makeC(b: B): Resource[IO, C] = Resource.eval(IO{ new C(b, "c") })

  val theD = autowire[D](makeC _, makeB _)
}

We need to swap results of makeC and makeB in the result composition order and also create an instance of class A with the primary constructor so the generated code looks like this:

val theD = for {
    fresh$macro$1 <- Resource.pure[IO, A](new A())
    fresh$macro$2 <- makeB(fresh$macro$1)
    fresh$macro$3 <- makeC(fresh$macro$2)
    fresh$macro$4 <- Resource.pure[IO, D](new D(fresh$macro$3))
} yield fresh$macro$4

Real app example

These simple examples look interesting, but now let’s take a look at a real app use case. For the sake of this blog post, we’re going to use a simple application, that:

  • loads configuration,
  • exposes Http API,
  • connects to two databases,
  • runs a background task that fetches data from third-party services with HTTP client.

Beforehand, I would recommend getting familiar with the code in this commit as I’m going to focus only on the Main class.

Let’s start with a vanilla main class that defines all the required dependencies and then starts the main processes (background task and http API) explicitly. The background task is just a list of crawlers that periodically collect data from external services and save it in the databases. A list of services that should be crawled is defined in configuration, the crawlers do not depend on database connection and http client directly, but they use domain services, so the function that creates crawlers might be defined as:

def buildCrawlers(
   cfg: CrawlersConfig,
   crawlingService: CrawlingService,
   serviceA: ServiceA,
   serviceB: ServiceB
): Crawlers = Crawlers(cfg.services.map(Worker(crawlingService, serviceA, serviceB, _)))

CrawlersConfig is fairly easy to access from the main config instance, but when it comes to the other services, we need to create them separately. To do so, we need to compose the configuration resource, transactors for both databases, the http client resource, and, finally, create the services. To create transactors, we can use the following function:

def buildTansactor(cfg: DatabaseConfig): Resource[IO, HikariTransactor[IO]] = {
 for {
   connectEC <- doobie.util.ExecutionContexts.fixedThreadPool[IO](cfg.connectThreadPoolSize)
   xa <- HikariTransactor.newHikariTransactor[IO](
     cfg.driver,
     cfg.url,
     cfg.username,
     cfg.password,
     connectEC
   )
 } yield xa
}

The creation of Crawlers dependencies may be defined as follows then:

ConfigLoader
 .loadConfig()
 .to[Resource[IO, *]]
 .flatMap { cfg =>
   buildTansactor(cfg.dbA).flatMap { xaA =>
     val dbReaderA = new DbReaderA(xaA)
     val dbWriterA = new DbWriterA(xaA)

     val serviceA = new ServiceA(dbReaderA, dbWriterA)
     buildTansactor(cfg.dbB).flatMap { xaB =>
       val dbReaderB = new DbReaderB(xaB)
       val dbWriterB = new DbWriterB(xaB)

       val serviceB = new ServiceB(dbReaderB, dbWriterB)
       Http4sBackend.usingDefaultBlazeClientBuilder[IO]().map { client =>
         val crawlingService = new CrawlingService(client)

         val crawlers = buildCrawlers(cfg.crawlers, crawlingService, serviceA, serviceB)
     }
   }
 }
}

That’s quite a lot of error-prone code required to create a simple service and we still need to create one more service to run the HTTP server. Fortunately, we’ve already defined most of the necessary dependencies, so we can just add a few lines to the last map above.

val collectorRegistry = CollectorRegistry.defaultRegistry

val endpoint = new Endpoints(serviceA, serviceB)
val api = new HttpApi(endpoint, collectorRegistry, cfg.httpServer)

Dependencies(crawlers, api)

Finally, we need to run the app dependencies with"

.use(deps => deps.crawlers.value.traverse(_.use(_.work()).start) 
>> deps.api.resource.use(_ => IO.never))

The composition of resources and creation of intermediate services is a part that may be simplified with autowire. To do so, we need to define helper traits that we are going to use to tag transactors and pass all the required dependencies to autowire.

trait DbA
trait DbB

autowire[Dependencies](
//Firstly, we need to pass the required configuration
 cfg.httpServer,
 cfg.crawlers,
//Then tagged transactors
 buildTansactor(cfg.dbA).taggedWithF[DbA],
 buildTansactor(cfg.dbB).taggedWithF[DbB],
//If we don’t want to touch the services, we need to define the factory methods that will be used instead of default constructors
 (xa: Transactor[IO] @@ DbA) => new DbWriterA(xa),
 (xa: Transactor[IO] @@ DbA) => new DbReaderA(xa),
 (xa: Transactor[IO] @@ DbB) => new DbWriterB(xa),
 (xa: Transactor[IO] @@ DbB) => new DbReaderB(xa),
//The last of crawlers dependencies - http client
 Http4sBackend.usingDefaultBlazeClientBuilder[IO](),
//metrics registry used by http server
 CollectorRegistry.defaultRegistry,
//And finally, factory method that builds crawlers for us
 buildCrawlers _
)

To run the dependencies, we use exactly the same code as in the previous version.

If you are interested in more complex usage of autowire, please have a look at this class in bootzooka.

Error handling

Now we have an almost boilerplate-free Main class, so we can pick up another task. As the development goes on, we may find that the Workers should use a separate thread pool. Right, so to implement it, we add a new simple wrapper class:

class CrawlerEC(val underlyingEC: ExecutionContext) extends AnyVal

We pass it in the CrawlingService constructor and use the underlying execution context to eval the crawl effect. When trying to run the application, the compilation fails with the following error:

[error] [...]/src/main/scala/com/softwaremill/macwire/autowire/Main.scala:50:31:  
Failed to create an instance of [com.softwaremill.macwire.autowire.Dependencies].
[error] Missing dependency of type [scala.concurrent.ExecutionContext]. 
Path [constructor Dependencies].crawlers -> [method buildCrawlers].crawlingService 
-> [constructor CrawlingService].crawlerEC
-> [constructor CrawlerEC].underlyingEC

Ah, right, we forgot to create an instance of this new execution context. autowire in such cases points to the exact path under which it failed to wire an instance, so it should be easy to figure out which instances we need to add. In this case, we need to define only one missing resource:

def buildCrawlerEC(cfg: CrawlersConfig): Resource[IO, CrawlerEC] =
 doobie.util.ExecutionContexts.fixedThreadPool[IO](cfg.services.size).map(new CrawlerEC(_))

And then add it to the arguments list of autowire.

Another refactor task may involve removing Prometheus integration for any reason. It would mean that we no longer need to pass CollectorRegistry to HttpApi class, so we can remove it. When we try to compile with these changes it fails with:

[error] [...]/src/main/scala/com/softwaremill/macwire/autowire/Main.scala:53:31: 
Not used providers for the following types 
[io.prometheus.client.CollectorRegistry]

autowire checks if all passed dependencies were used in the wiring process and when any of them is redundant, it fails with this error. To fix it, we need to simply remove CollectorRegistry in the Main class.

Yet another error that we may face is:

[error] [...]/src/main/scala/com/softwaremill/macwire/autowire/Main.scala:52:31: 
Ambiguous instances of types [X]

It basically means that there is more than one way for creating an instance of the target class from the given dependencies. In our example, we used multiple doobie HikariTransactor[IO]. To avoid this error and point autowire instances that should be used in a given class, we used tagging:

​(xa: Transactor[IO] @@ DbA) => new DbWriterA(xa)​

For now, it’s a recommended way for dealing with such ambiguities (here you can find a useful tagging util).

Check:
Autowire: Zero-Cost Dependency Injection

Summary

autowire originates from this issue, so at this point, I’d like to thank schrepfler for bringing it to the table. An ancestor of this feature is wireRec that was inspired by a small library called jam.

autowire is currently an experimental feature, which means it’s not battle-tested yet, therefore it’s especially important for us to receive as much feedback as possible. If you find an unexpected behavior or come up with a new great feature that should be implemented, please do not hesitate to create an issue or post it on the Gitter channel. Our future plans on this feature are described in Github issues, any kind of participation will be more than welcome.

Blog Comments powered by Disqus.