Direct-style Bootzooka: 2024 update
As technology never stops evolving, it's time to update Bootzooka, our webapp/microservice application template based on TypeScript and Scala. This time, we focus on the backend, adopting Java 21 with Virtual Threads, the "direct" programming style, and Scala 3.
Let's take an overview of the changes and zoom in on a couple of technical details!
The stack
After the update, the backend stack is as follows:
- Scala 3: backend programming language
- Tapir + Netty: HTTP server
- Magnum: database access
- PostgreSQL: database
- Ox: concurrency, resource & error handling
- OpenTelemetry: metrics, tracing
- sbt: build
The frontend stack remains unchanged:
- TypeScript: frontend programming language
- React: UI framework
- Swagger: interactive API docs
- yarn: build
Application logic
In the latest version of Bootzooka, we depart from the purely functional style on the backend and adopt the so-called "direct" one. Thanks to the availability of virtual threads in Java 21, we can do this without compromising performance. We can write code in a "synchronous" way with blocking operations. Scheduling of virtual threads onto OS threads is delegated to the JVM. Of course, this isn't without tradeoffs: some of these I discussed, e.g., in my Scala.IO talk.
Of course, we still use functional programming, though not in its "pure" form. In practice, this means using immutable data structures, algebraic data types with pattern matching, functions and higher-order functions operating on data structures as a basic building block, representing errors as values, and leveraging the fact that everything is an expression.
The service code is lighter regarding syntactic overhead and, hence, more approachable and readable. As an example, here's a fragment of the user registration flow:
val id = idGenerator.nextId[User]()
val now = clock.now()
val user = User(id, loginClean, loginClean.toLowerCased,
emailClean.toLowerCased, User.hashPassword(password), now)
val confirmationEmail =
emailTemplates.registrationConfirmation(loginClean)
logger.debug(
s"Registering new user: ${user.emailLowerCase}, with id: ${user.id}")
userModel.insert(user)
emailScheduler(EmailData(emailClean, confirmationEmail))
apiKeyService.create(user.id, config.defaultApiKeyValid)
A straightforward sequence of steps expresses the business process's essence.
Error handling
One of the main aspects of any programming language and style within that language is how errors are handled. In Bootzooka, we're taking the approach proposed by Ox, which has three components:
- (unchecked) exceptions for "unexpected" errors; in the context of an HTTP request, this means that we return a 500 status code and log the exception for further inspection. Generally, upon such an exception we terminate the current "unit of work" (here, the request)
- errors-as-values for "expected" or recoverable errors, represented as the
Either[E, R]
type, whereE
is the error type andR
is the successful result type. This follows the conventions in many FP languages - the
IO
capability to specify which methods perform I/O operations. This not only informs the developer of possible failure modes (where I/O-related exceptions might happen) but also sets the expectations as to the amount of time invoking a method might take
Using Either
s to represent errors-as-values has several advantages. First, such errors are fully typed—we know exactly what kinds of errors to expect. Second, we can use Scala 3's union types to allow for the possibility of multiple otherwise unrelated error types to be reported, e.g., a method might return an Either[UserExists | MalformedEmail, User]
result (we didn't have the need to use this in Bootzooka, but in more complex applications, it probably will become useful quickly).
Thirdly, Either
is a straightforward data type. You can pattern-match on it to perform recovery actions in case of an error or to process the successful result; alternatively, you can use one of the higher-order functions to transform the error/success types or fold everything into another value. An Either
is both easy to introduce, and easy to eliminate.
Finally, we can do Rust-style error unwrapping within bounded blocks thanks to the boundary-break mechanism. For example:
either {
val loginUpdated = changeLogin().ok()
val emailUpdated = changeEmail().ok()
val anyUpdate = loginUpdated || emailUpdated
if anyUpdate then sendMail(findById(userId).ok())
}
The type of the above code block is Either[Fail, Unit]
. The code might short-circuit on any of the .ok()
invocations if the result of changeLogin()
, changeEmail()
, or findById()
is an error (a left-side of an Either
value).
Note that the places where errors might occur are clearly marked using .ok()
. Hence the errors are visible, but they don't get in the way of the "happy path", which is the unique route that gets us to our goal.
Database access
To interface with the database, we decided to use Magnum, a relatively young but impressive library. It's a rather thin layer on top of Java's JDBC APIs. However, it also feels like it's the proper abstraction. It's not "magical" at any point, but it also saves you from writing a ton of boilerplate code.
Magnum leverages the full power of Scala 3, using contextual abstractions and principled meta-programming. The former means that any code that should be executed within a transaction needs to require the DbTx
capability. When an instance of DbTx
is in scope, we can use any of the functionalities provided by Magnum to query or update the database.
To run the transaction, we should use the transact
function, which provides the DbTx
capability for executing the provided code block. Of course, we can compose transactions using multiple methods, each of which requires DbTx
.
For example, both the sendMail
and findById
methods have to be run within a transaction (sending a mail puts it into a queue—an instance of the outbox pattern). This is propagated to the method which calls both of these, changeUser
:
def findById(id: Id[User])(using DbTx): Either[Fail, User] = …
def sendMail(user: User)(using DbTx): Unit = …
def changeUser(...)(using DbTx): Either[Fail, Unit] =
…
// notify the user than an update happened
sendMail(findById(userId).ok())
Magnum can generate the most commonly accessed queries through a Repo
macro (this includes e.g. findById
or update
). There's also the possibility of dynamically building filtered queries using Spec
or running arbitrary queries, while centrally managing table and column naming through a case class. That way, the data access layer is quite minimal:
class UserModel:
private val userRepo = Repo[User, User, Id[User]]
private val u = TableInfo[User, User, Id[User]]
def insert(user: User)(using DbTx): Unit =
userRepo.insert(user)
def findById(id: Id[User])(using DbTx): Option[User] =
userRepo.findById(id)
def updatePassword(userId: Id[User], pwd: Hashed)(using DbTx): Unit =
sql"""UPDATE $u SET ${u.passwordHash} = $pwd
WHERE ${u.id} = $userId""".update.run().discard
Error handling within Magnum
One modification that we had to make is making Magnum Either
-aware. When a service method returns an Either
, the transaction should only commit if the result is successful (a Right
). A Left
result means an error; we certainly don't want to commit partial updates!
To implement this, we've hidden the Magnum interface behind a custom DB
service, which properly handles the results:
class DB(dataSource: DataSource & Closeable)
extends Logging with AutoCloseable:
def transactEither[E, T](
f: DbTx ?=> Either[E, T])(using IO): Either[E, T] = …
In some cases, we have service methods that don't result in "expected" errors (don't return an Either
). Thus, we need a transact
variant that works for any return type T
. However, such a method would also be usable with Either
, and calling it by mistake (or because of refactoring) would be an ugly and hard-to-detect bug.
Luckily, we can use Scala's NotGiven
to ensure that our transact
method is not called with a function that returns an Either
:
class DB:
def transact[T](
f: DbTx ?=> T)(using NotGiven[T <:< Either[_, _]], IO): T = …
Background services
Just as error handling, concurrency is handled using Ox
(via structured concurrency). In the case of Bootzooka, this is only needed when starting background email-sending services but illustrates the general mechanisms quite well.
The application's entry point uses OxApp
, which provides a root Ox
concurrency context. This means we can fork top-level daemons which will work as long as the application does.
In the case of the email-sending process, we start a forever-loop, which sends subsequent email batches, in a background fork:
def startProcesses()(using Ox, IO): Unit =
fork {
forever {
sleep(config.emailSendInterval)
try sendBatch()
catch case e: Exception =>
logger.error(errorMsg, "Exception when sending emails")
}
}
Note that we need the Ox
capability to start forks. Moreover, we only log exceptions (no recovery logic would make sense here). The entire concurrency scope will end if there's a bug and the fork's logic throws an unhandled exception. Because it's the root scope, this would terminate the application.
We're also starting the HTTP server, which also requires an Ox
concurrency scope. This is all handled at the application's entry point:
object Main extends OxApp.Simple with Logging:
override def run(using Ox, IO): Unit =
val deps = new Dependencies() {}
deps.emailService.startProcesses()
val binding = deps.httpApi.start()
logger.info(s"Started on ${binding.hostName}:${binding.port}.")
// blocking until the application is shut down
never
We need to block at the end of run
so that the application doesn't shut down right after starting!
Dependency injection
We continue using constructor-based dependency injection, which works quite well. One change that we had to make is to abandon auto-wiring using macwire, as there's no autowire
functionality implemented for Scala 3 yet. It's a long-standing issue, so if you'd like us to prioritize it, please vote!
The lack of autowiring makes the dependency-wiring code a bit boilerplate'y, but in a bearable way. One important feature we use here is the ability to attach resources to the current Ox
concurrency scope. That way, when the application is interrupted, we can register cleanup code to e.g., properly close the database connections:
trait Dependencies(using Ox, IO):
// …
lazy val db: DB = useCloseableInScope(DB.createTestMigrate(config.db))
Future work
There are still a couple of improvements to be made. For example, a software project is never complete, even as simple as Bootzooka. Here are some updates that you can expect in the coming weeks:
- generating OpenAPI specification during the build, using it on the frontend
- revisiting the service logic, as it might need restructuring/ simplification
- adding JVM monitoring using OpenTelemetry
- upgrading to sttp-client4
Try it on your own!
Give Bootzooka a spin. It comes in a ready-to-deploy package, producing either a fat jar or a docker image. The getting started docs will guide you through setting up a local database, building the frontend and backend, and, if needed, renaming the project so that your next microservice might be Bootzooka-based.
As always, please give us feedback on which technology stack choices you like, dislike, or have ideas for improvement!