Bootzooka 2022: cats-effect 3, autowire & tapir
Bootzooka is our template project to quickly bootstrap a simple Scala+React-based microservice or webapp.
It contains the basic infrastructure such an application might need: relational database access, an HTTP API, fat-jar/docker/Heroku deployments. The basic user-management functionality is available as well, that is registering users, logging in and resetting passwords, serving both as a template for developing other services and as a jumpstart to focus on core business requirements, instead of reimplementing security.
By design, it’s an “opinionated” project, using a hand-picked set of libraries. While it’s fairly easy to replace a particular component, the Bootzooka project focuses on providing good developer experience with the choice we’ve made.
However, we all know that our industry is in constant flux! That means that from time to time, we need to update the stack; 2022 is no different. There’s a couple of important updates that we’d like to share.
cats-effect 3
The first major change is updating from Monix to cats-effect 3. While Monix served us well, it seems that most new development is currently focused on cats-effect, so this platform seems to be a more Future
-proof (pun intended) solution.
Both cats-effect and Monix are functional effect systems, hence the philosophy of structuring code remains the same. On the surface, it might seem that the migration amounts to changing the datatype used for describing side-effecting computations to IO
, instead of Monix’s Task
. And indeed, that was one of the initial steps. But as always, the devil is in the details!
Correlation ids
One of the features of Bootzooka is its support for correlation ids. A correlation id is an identifier that:
- is read from the incoming HTTP requests’ headers, or a new one is generated,
- is associated with the request for the whole duration of request processing,
- is added to any outgoing HTTP requests in a header,
- is included in all log messages produced by the application during request processing.
Before, this was implemented using TaskLocal
s, transparently to the user; the signature of the business logic methods was unaffected and unaware that a correlation id is being passed behind the scenes. Because TaskLocal
s where also available as a ThreadLocal
, integration with logging (slf4j/logback in our case) was possible using MDC.
This changed in cats-effect 3. There is a similar construct, IOLocal
, however, it has some limitations compared to its predecessor. Firstly, its value is not available as a ThreadLocal
, you can only read it using IOLocal.get: IO[A]
. Note that the result type is not the A
value itself, but an effect yielding the local value, which means that this must be included in the overall effect returned by the method in question.
To include the correlation ids in log messages, we had to create a thin wrapper on top of slf4j’s loggers to read the current correlation id from the IOLocal
, populate the MDC, and only then call the underlying logging. This also means that our logging calls now return IO[Unit]
values. Here’s how our simplified FLogger
implementation looks:
class FLogger(delegate: Logger) {
def debug(message: String): IO[Unit] = CorrelationId.get.flatMap {
cid =>
MDC.put(MDCKey, cid)
try delegate.debug(message)
finally MDC.remove(MDCKey)
}
}
In non-side-effecting code that doesn’t return an IO
value, you can still perform the logging using the traditional “synchronous” API, but you won’t get the correlation ids.
Another problem is how to perform logging as part of Doobie’s transactions. When defining a transaction, you describe the logic using ConnectionIO
values, instead of IO
. How to read the current correlation id inside a transaction? We need a get
variant which yields a ConnectionIO[String]
, not an IO[String]
.
Solving this required a small Doobie hack; ConnectionIO
values are in the end interpreted as IO
values using a Transactor
. We just need a “special” instruction represented as a ConnectionIO
value which means “get the correlation id”, and a wrapper for the “real” transactor which will interpret our instruction accordingly. If you’re curious, the code is here.
Embedding effects in transactions
The Doobie logging problem described above is a symptom of another change. Using cats-effect 3 and Doobie, it is no longer possible to embed arbitrary IO
effects inside a ConnectionIO
transaction description. Before, we could have written someTask.to[ConnectionIO]
and in effect, we got a transaction fragment that executed the given side effect (such as logging or HTTP requests). The various use-cases and combinations of IO
and ConnectionIO
values are described in this article.
However, using the redesigned typeclass hierarchy and the less central role of IO
in cats-effect 3 (compared to cats-effect 2, which is used by Monix), we cannot do that anymore. And we did use the functionality of embedding arbitrary side-effects inside a transaction in Bootzooka in a couple of places:
- generating ids (which are random),
- accessing the clock,
- and now, additionally, logging (see previous section).
Luckily, ConnectionIO
implements the Sync
typeclass from cats-effect. Hence a solution is possible; any code that needs to be run both as a regular side-effecting operation (yielding an IO
), and as part of a transaction (yielding a ConnectionIO
) needs to be written using the tagless-final style so that we can provide an arbitrary effect type (as long as it implements the right typeclasses). For example, our id generator became:
object DefaultIdGenerator extends IdGenerator {
override def nextId[F[_]: Sync, U](): F[Id @@ U] =
Sync[F].delay { SecureRandomId.Strong.generate.taggedWith[U] }
}
There are some downsides to this solution, as now parts of the services are written in terms of a fixed effect type, IO
, and parts are written using tagless-final. The former is simpler, the latter more powerful. The bright side is that usually we’ll need only a handful of services to be usable in both IO
and transactional contexts.
autowire
Another important change is how we handle dependencies and implement dependency injection. In previous Bootzooka incarnations, we had a mix of constructor-based dependency injection and manual Resource
wiring. That is, we had to allocate some resources first, then create some of the services using constructors so that we could create other resources, and finally, create the rest of the dependency graph using constructors once again.
This worked well on a small scale, but in larger projects, the amount of boilerplate became significant. Luckily, macwire recently got a new feature called autowire
, which integrates tightly with cats-effect 3 and solves the above problems. It also allowed us to get rid of the last remaining cake pattern leftovers, that is module-traits. You can read in detail how autowire works in an article by Mateusz Borek. Meanwhile, I’ll focus on how we use it in Bootzooka.
autowire
, as the name suggests, creates the dependencies automatically, as long as it’s possible. That is, if there’s a primary constructor defined on a class, it will try to use it to create the class’s instance. The values for the constructor parameters are created recursively until we get to the dependency graph leaves. The leaves here are classes with a no-arg constructor or dependencies explicitly provided to autowire’s invocation.
The nice thing about autowire
is that it also handles instances given as a Resource[IO, _]
, IO[_]
or factories (functions returning a dependency instance that might be wrapped with IO
or Resource
). That’s why autowire’s result type is always Resource[IO, _]
. The returned resource will allocate and deallocate the resources in the correct order, creating the appropriate dependencies along the way.
This allowed us to centralise Bootzooka’s dependency injection process in a single class, Dependencies
, which are then used by the application’s entry point, Main
. Below, you can find a slightly simplified version:
case class Dependencies(api: HttpApi, emailService: EmailService)
object Dependencies {
def wire(
config: Config,
sttpBackend: Resource[IO, SttpBackend[IO, Any]],
xa: Resource[IO, Transactor[IO]],
clock: Clock
): Resource[IO, Dependencies] = {
autowire[Dependencies](
config.api,
config.user,
config.passwordReset,
config.email,
DefaultIdGenerator,
clock,
sttpBackend,
xa,
buildHttpApi _,
new EmailService(_, _, _, _, _),
EmailSender.create _,
new ApiKeyAuthToken(_),
new PasswordResetAuthToken(_)
)
}
}
There’s a couple of dependencies that can’t be created automatically and hence are passed explicitly, such as:
- parsed (and typed) configuration, read using pureconfig
- the resources needed to create an sttp client backend and a Doobie transactor
- in case we are using
trait
s as a dependency, we also need to explicitly specify the implementation to use, such as withnew ApiKeyAuthToken(_)
If a dependency is not present (or if any unused dependency is provided), autowire
will let you know with a compile-time error message:
[error] Dependencies.scala:46:27: Failed to create an instance of
[Dependencies].
[error] Missing dependency of type [AuthTokenOps[ApiKey]].
Path [constructor Dependencies].api ->
[method buildHttpApi].userApi ->
[constructor UserApi].auth ->
[constructor Auth].authTokenOps
Yes, that’s right - autowire
is a macro that generates the wiring code, hence everything happens at compile-time. No boilerplate, reflection or startup penalty, but a quick feedback cycle instead.
tapir everywhere
With tapir approaching 1.0 release, we’re able to use more of its functionality to describe the HTTP API of Bootzooka. The underlying HTTP server implementation is still http4s, however all of the functionality above handling raw HTTP requests is now implemented using tapir.
Firstly, tapir comes with built-in endpoints to serve static content, which means that in the deployment scenarios where the frontend is bundled with the backend in a single jar, we can use tapir to serve it.
Secondly, a number of out-of-the-box interceptors implement many cross-cutting functionalities and can be used with any tapir server interpreter. This includes:
- json-formatting of server-level errors (exceptions, malformed requests),
- logging,
- CORS,
- Prometheus metrics,
- and a custom interceptor to extract the correlation id.
The interceptors are configured using ServerOptions
, and in the case of Bootzooka, this amounts to the following:
val serverOptions: Http4sServerOptions[IO, IO] = Http4sServerOptions
.customInterceptors[IO, IO]
.prependInterceptor(CorrelationIdInterceptor)
// all errors are formatted as json
.defaultHandlers(msg =>
ValuedEndpointOutput(http.jsonErrorOutOutput, Error_OUT(msg)),
notFoundWhenRejected = true)
.serverLog {
// using a context-aware logger for http logging
val flogger = new FLogger(logger)
Http4sServerOptions
.defaultServerLog[IO]
.doLogWhenHandled((msg, e) =>
e.fold(flogger.debug[IO](msg))(flogger.debug(msg, _)))
.doLogAllDecodeFailures((msg, e) =>
e.fold(flogger.debug[IO](msg))(flogger.debug(msg, _)))
.doLogExceptions((msg, e) => flogger.error[IO](msg, e))
.doLogWhenReceived(msg => flogger.debug[IO](msg))
}
.corsInterceptor(CORSInterceptor.default[IO])
.metricsInterceptor(prometheusMetrics.metricsInterceptor())
.options
Finally, tapir gained security features by introducing a dedicated list of security-related input parameters. This means we are now able to define two “endpoint blueprints”: one for public endpoints and one for secure endpoints. The latter might also include the server-side security logic built-in, so any specialisations of this endpoint will only need to provide the endpoint-specific inputs/outputs and the business logic.
In Bootzooka, the base endpoint fixes the error channel (which is used if there are business-logic-level errors, such as when an entity in a database cannot be found), which is visible in the type; a secure endpoint additionally specifies that an Id
identifier is read from the request, and is part of the security input:
val baseEndpoint:
PublicEndpoint[Unit, (StatusCode, Error_OUT), Unit, Any] = …
val secureEndpoint:
Endpoint[Id, Unit, (StatusCode, Error_OUT), Unit, Any] = …
Finally, we can create an extensible endpoint with the security logic provided:
val authedEndpoint = secureEndpoint
.serverSecurityLogic(authData => auth(authData).toOut)
As in previous versions, tapir endpoint descriptions are used to expose documentation using the Swagger UI in OpenAPI format. Using the SwaggerInterpreter
, we can obtain tapir endpoints that expose the documentation for other tapir endpoints:
val docsEndpoints = SwaggerInterpreter(swaggerUIOptions =
SwaggerUIOptions.default.copy(contextPath = apiContextPath))
.fromServerEndpoints(mainEndpoints.toList, "Bootzooka", "1.0")
What’s next?
Check https://softwaremill.com/direct-style-bootzooka-2024-update/
Bootzooka is evolving together with functional programming and the Scala ecosystem. What kind of updates might we see in future releases? Of course we don’t know yet, but there are some candidates - such as moving to Scala 3, considering using ZIO or migrating towards a tagless-final style entirely.
What’s the direction you’d like to see Bootzooka follow? Let us know in the GitHub issues, either vote/comment on existing ones or create new discussion items!
Our overarching focus is on simplicity, so if you see some boilerplate that is becoming tedious to manage in larger projects or an area that feels complex to understand or use, let us know - we’ll try to fix it preferably in code, or through documentation.
If you’d like to try Bootzooka, the getting started page should be helpful, detailing the process of building, renaming the project to match your use-case, and deploying using one of the supported deployment methods.