Why use Scala

Why Scala?

Why use Scala

Introduction

I am a Scala developer who had been working with Java and occasionally Javascript / Typescript for a few years before I transitioned to Scala and fell in love with programming all over again. I am currently working on a huge project for an international company that consists of many complex services written in various languages, using many different technologies. One of the services is written in Scala. The motivation for this blog post was the natural questioning of the use of Scala in the company, as well as my desire to comprehensively answer the question “Why should we use Scala?”. 

Let’s take a closer look and see why Scala is the right choice for building backend applications.

Strengths of Scala

Scala runs on the Java Virtual Machine (JVM) which is the proven engine for enterprise software development. I doubt anyone has ever been fired for choosing JVM. Unlike other runtimes or languages it has been around for almost 30 years and has a very rich ecosystem. 

JVM has tons of well-known benefits. To mention just a few:

  • Build once, run anywhere. Whether it’s a new shiny Macbook pro, linux servers or AWS lambda, JVM languages can run on any platform as long as it’s possible to install JVM on the platform. Unlike native languages which are compiled to match specific platforms, JVM languages like Java, Scala, Kotlin or Closure are compiled to bytecode, which is executed by the JVM.
  • A common API to work with platform specific resources. Any JVM language has access to an API to handle file IO, memory, networking etc.
  • Memory management. JVM devs don’t need to think about when to allocate memory and when to release it. Garbage Collector does it all automatically, making memory leaks difficult to introduce.

GraalVM can make your JVM-based applications run faster and can offer rapid startup times. Project Loom introduces fibers – native green threads – which solve the asynchronous problem, making operations that block operation system (OS) level threads, run non-blocking. All this without the need for any reactive framework. Simply put, there has never been a more exciting time to be a developer on JVM.

Now that we have chosen JVM as our platform, let’s take a look at the languages and start with this bold statement, which we will justify later:

Scala is the best language on the JVM

Scala’s type system can catch most of the bugs during compile time. Its declarative, non-verbose syntax makes writing code very productive. While I am excited to see Java language making big progress, it’s also true that all these new features like pattern matching, sealed interfaces, record classes or type inference were in Scala 15 years ago, with much more ergonomic syntax.

However, there are many features that won’t make it to Java anytime soon:

  • Very strong type system. Types, union types, intersection types, abstract types, higher-kinded types, path dependent types, phantom types, type bounds etc. allow us to do type level programming and solve some type of problems at compile time instead of runtime.
  • Macros & Metaprogramming allow you to turn your code into tree-like data representation. In that process, code can be generated, inspected or otherwise modified. Macro call can also fail, which at the call site will be visible as compile time error. For example with macros we can inspect some type and successfully compile user code only when the type satisfies some specific criteria or otherwise fail with custom compile time error message. We can generate instances of any typeclass at compile time. We can auto-wire the whole application’s dependencies and more.
  • Implicits. Extension methods. Declaration site variance, support for generalized algebraic data types (GADT) and many more.

I must admit that most of these features are not really a good selling point for day to day work on client projects, as some of them are quite complex. However, they are the building blocks for the incredibly powerful libraries and frameworks that have risen from the language. 

In comparison, syntax of Java has too much boilerplate and support for concepts like concurrency, parallelism, resource safety and functional programming is too error prone, low level and unidiomatic. The most popular Java framework – Spring – is heavily based on reflection and runtime processing of annotations, making it very hard to test, debug and reason about our codebase. Annotations are just markers / meta description of part of our code, doing nothing by themselves. Neither are they composable or typesafe. With too many of them, annotations feel like a language within a language designed just to overcome Java’s limitations. It’s true that once we learn a dictionary of annotations, they are super easy to use in hello world sized projects and getting started guides but I have witnessed them fall apart in more complex scenarios.

But let’s not talk about what other languages don’t have or cannot do. Let’s talk about what makes Scala great,  which is its most amazing, first class support for functional programming (FP).

You might also want to read Scala isn’t Hard: How to Master Scala Step by Step

Strengths of Functional Programming

To start with a little theory, Functional Programming is programming with functions. When we say functions, we mean mathematical functions, which are supposed to have the following properties:

  • total – defined for every input. For example, String#toInt is not total because it’s not defined for any String input like “foo”.
  • deterministic – the same input always gives the same output. Random#nextInt(100) is not deterministic because when we call it two times, we’re gonna get 2 different results.
  • pure – without side effects, with the only concern of computing the result. The side effects can be anything from console logging to calling external API.

If all functions need to be pure, it is probably not clear how anything useful can be done using functional programming. Scala, therefore, uses a functional effect system – the so-called IO, which is basically a wrapper for lazy computations. Object-oriented programming (OOP) languages also introduce some kind of wrappers. For example, the ones that allow working with asynchronous & concurrent computations (Future, Promise), missing values (Option) or errors (Try, Either).

Programs written in OOP languages contain a main method that has a bunch of statements, which are executed one after the other. In comparison, in FP Scala we write a description of our program that describes effectful computations/interactions with the outside world. The data structure that lets us safely manipulate effects and turn them into pure data is called the above-mentioned IO. In other words, IO represents programs as values. 

Let’s say we need to compose a number of asynchronous computations to run in parallel or we need to work with db transactions. It’s so much easier to do it when working with values. Instead of relying on annotations or thread-local transactional context, we can define a value which describes a computation that should be run in parallel / in transactional context. Any number of those values (IOs) can be retried, raced against each other or composed into the final computation description – our final program – which will be evaluated by IO runtime. The logic that creates the description is completely separate from the evaluation – from how transactions are managed or how threads are created and interrupted.

In essence, IO is able to give us all these benefits because it promotes our programs to be referentially transparent. Another huge selling point of functional programming is functional domain modelling. Let’s take a closer look at both.

Benefits of referential transparency

Before we dive into the benefits, let’s explain what we are talking about. Referential transparency (RT) means that we can safely, without changing the behaviour of a program, replace an expression with its value and vice versa. For example, look at the following code:

def sum(a: Int, b: Int) : Int = a + b

val s1 = sum(2, 3)
val s2 = sum(2, 4)

val result = s1 + s2
  
val result2 = sum(2,3) + sum(2, 4)

The meaning of the program did not change when we swapped s1 and s2 expressions with their values. result and result2 are the same.

On the other hand, the following side-effecting code which prints a message to the console and is wrapped into the Future,  is not referentially transparent:

val helloFuture = Future { println("Hello World!")}

val x = for {
   _ <- helloFuture
   _ <- helloFuture
} yield ()

val y = for {
   _ <- Future { println("Hello World!") }
   _ <- Future { println("Hello World!") }
} yield ()

Can you tell the difference between x and y? As Future is eager, Hello World! is printed to the console as soon as helloFuture is evaluated, so Hello World! will be printed to the console only once and then x will get evaluated to Future[Unit]. When y is evaluated, the greeting message is printed to the console twice.

Easy, fearless refactoring

With RT, the whole class of refactoring bugs go away. We can refactor without fear of breaking existing behaviour and we can safely throw pieces of our codebases around as everything is a value. For example, we can freely compose and manipulate asynchronous computation without worrying about side effects, as they are all deferred.

Testability

We use only expressions that compute values and don’t produce side effects of interacting with the world in any way. The following method is not referentially transparent because it writes to the console.

 def getMoney(money: BigDecimal): BigDecimal = {
   val withInterest = money * 1.1
   println(s"Here's your money: ${withInterest}")
   withInterest
 }

Now if we replace the expression with its value:

val a = getMoney(10)
val twice = a + a

The console log will be printed only once, because the getMoney function is not pure. Functions without side effects, where the same input always results in the same output, are very easy to test, no mocking required.

Local reasoning

RT is also a powerful mental tool. It gives us the ability to quickly inspect code, read, understand, reason about, change and deconstruct the meaning of our programs without altering it in any way.

RT code is not tied to a context. If we can’t make a substitution without altering the behaviour, then we are taking into account something else – some side effect – apart from the expression we are dealing with. Non-referentially transparent code requires non-local reasoning, which results in having too many things in our head at once. With RT, we understand code by understanding its individual parts, and then the global behaviour is the sum of the individual behaviours. To do this, we analyse individual portions of the code, see what they would produce given certain inputs, and then put them back together. 

Power

With the idea of ‘describe, don’t do‘ comes huge power. Earlier we talked about Scala’s Future data type, which can help working with async and concurrent computations. However, there’s a fundamental difference between Future and IO. Future is a handle to an already running computation, while IO is a blueprint, which can be timed out or we can do something useful (parallelize) before the recipe executes. All the hard work is already baked into the IO runtime. On the other hand we cannot do much with the Future – it will eagerly start running and it’s not even possible to interrupt it.

Data modeling in FP

Functional domain modeling makes FP a much more suited paradigm to describe real world than object-oriented programming. With FP domain modelling, developers are able to write very precise and correct domains, without ever learning plenty of cumbersome OOP design patterns, which often seem more like recipes to overcome language syntax limitations rather than useful tools.

In OOP, we tend to think about data and functionality together. OOP modelling has lots of tools like inheritance, interfaces, abstract classes, classes. Classes have properties, getters, setters, functions that work on data. 

The OOP model often relies on inheritance. We usually look at the codebase and extract common patterns into interfaces or abstract classes. Then we create concrete classes that inherit those interfaces, sometimes even more than one. For example, here is an Event interface which gives us a guarantee that all the children will have a time field.

trait Event {
   def time: Instant
 }

trait UserEvent extends Event {
  def userName: String
}

trait DeviceEvent extends Event {
  def deviceId: Int
}

In OOP there are many ways to model the domain and many ways to do it poorly, by introducing an invalid state. In the example below, Person either has a job and employment date or it has neither a job nor employment date. Now how should our program behave if we receive Person("Jaro", None, Some("05.06.2020")) ?

case class Person(
   name: String,
   job: Option[Job],
   employmentDate: Option[LocalDate]
)

In functional domain modelling, we use algebraic data types (ADT). ADTs contain no functionality, they are composable pure data. They are defined as a closed set of all possible values under one, common interface. ADTs consist of sum and product types. Sum type is a type that could hold a value that could take on several different but fixed types. Like enum or Either. Product type is a type that is formed by a composition of other types. Like a case class (record class, data class) or a tuple.

FP domain modelling does not use inheritance. Let’s revisit our example with the Event interface.  Case classes serve to model product types and sealed traits are used to model sum types. The compiler has very precise information about the number of values the type can contain.

case class Event(time: Instant, eventType: EventType)

sealed trait EventType

case class UserEvent(userName: String) extends EventType
case class DeviceEvent(deviceId: Int)  extends EventType

FP domain modelling makes incorrect data unrepresentable. Here is the Person data type from the example earlier. In this model, it’s not possible to create a Person with unemployed status and employment date.

case class Person(
   name: String,
   status: EmploymentStatus
)

sealed trait EmploymentStatus
 
case object Unemployed extends EmploymentStatus
case class Employed(job: Job, employmentDate: LocalDate) extends EmploymentStatus

FP doesn’t abstract over data

Data are composable first class values. The domain model looks like a tree, where at every node in the tree we have either a sum type or a product type. Data is anything that is formed from products and sums – all the way down to the leaves (case objects, primitives, collections). 

sealed trait Tree[A]

object Tree {
   final case class Leaf[A](value: A) extends Tree[A]
   final case class Fork[A](left: Tree[A], right: Tree[A]) extends Tree[A]
}

This idea of tree-like data structures that can be parsed, transformed or translated, powers a lot of Scala’s DSLs.

No coupling between data & service

FP provides a clear solution to what data is and what service is. In pure FP codebases we have no coupling between data and services. If it is not data, then it is a service. Services depend on data but data don’t depend on services. When we think about an onion architecture, data are in the centre of the onion.

Services are not data and we should use interfaces for those. They are a perfect fit for open ended things like UserRepository, where we want to provide test implementation, production implementation and so on. They allow us to plug in different components. They serve as a contract between various layers of our application.

Newtypes, smart constructors

Sums and products are all we need to model our data. However, sometimes it’s hard (time and amount of code consuming) to model all of the possible cases just with ADT. Imagine writing an ADT that would describe an email address, or 5 digit code. For convenience, FP programmers use smart constructors.

Here we make Email a case class with a private constructor, so it’s not possible to construct an Email directly. Instead we override the apply method which returns an Option[Email]. We return Some[Email] in the case that the email matches the regex, and None otherwise.

case class Email private (value: String)
object Email {
  def apply(email: String): Option[Email] =
    if (email.matches("""/\w+@\w+.com""")) Some(new Email(email) {}) else None
}

Newtypes are another useful feature very frequent in FP domain models. Let’s take a look at the following code.

case class Customer(name: String, surname: String, email: String, address: String)

val customer1 = Customer("[email protected]", "Jaro", "Street 12345, Kosice", "Regec")

What has happened here? The code compiles just fine, however, it contains a lot of bugs. We put in email address` instead of name and so on. 

Newtypes give us the tools to model our domain correctly and avoid future bugs. There are various newtype approaches in Scala with various compile time or runtime overhead, but in essence the idea is like the following.

case class Customer(name: Name, surname: Surname, email: Email, address: Address)

Customer(Name("Jaro"), Surname("Regec"), Email("[email protected]"), Address("Street 12345, Kosice"))

Case class, sealed traits, smart constructors, newtypes are all the tools we need to help us write correct domains and avoid runtime errors.

Strengths of ZIO

ZIO is the framework that leverages the best of Scala and FP to help us rapidly and sustainably ship robust, resilient, scalable, efficient, resource-safe, cloud-native applications. To put it into perspective with what we already know, ZIO is the effect system to allow us to do pure functional programming in Scala. Now let’s see it in action.

You might also want to read Introduction to Programming with ZIO Functional Effects

Tour of benefits

Easy concurrency

Concurrency is easy! ZIO makes all the concurrency problems go away. The ZIO concurrency model is lock-free, non-blocking and resource-safe with composable concurrency primitives and great combinators.

Let’s say we have a bunch of IDs and we want to call some external service that would get us back some data.

val ids: List[UUID] = List(???)

val results: IO[Throwable, List[Data]] = 
   ZIO.foreach(ids){ 
      id => callExternalApi(id)
   )

What if we want to fetch data in parallel for each of those IDs? Don’t blink. We just call foreachPar instead of foreach.

val results: IO[Throwable, List[Data]] =
   ZIO.foreachPar(ids){
     id => lookupDataById(id)
   }

The data consistency problem is solved by Ref. Ref allows us to build stateful effects. In the following example, 100 fibers are concurrently updating Ref

 for {
   ref   <- Ref.make(0)
   _     <- ZIO.foreachPar(1 to 100)(_ => increment(ref, 10))
   value <- ref.get
 } yield (value)

Data coordination problems can be solved by using Promise. Promise is a synchronization primitive that can be completed exactly once. In the following example one fiber (virtual thread) sleeps for 10 seconds before “computing” a number. The other fiber waits for that work to be done without introducing a spin loop and wasting resources. fork is a method to create a fiber, but more on that later.

for {
   promise <- Promise.make[Nothing, Int]
   _       <- (ZIO.sleep(10.seconds) *> promise.complete(ZIO.succeed(1000))).fork
   _       <- Console.printLine("Started sleeping in another fiber")
   number  <- promise.await
 } yield number

ZIO has many other primitive concurrent data structures like Queue, Hub, Semaphore as well as synchronization tools like CountdownLatch, ReentrantLock and more. ZIO also supports Software Transactional Memory (STM), which allows us to compute multiple operations and perform them in a single atomic transaction. 

Scalable concurrency

Threads on JVM are a scarce resource. They map one to one to OS level threads and they have their own stack memory space. It’s expensive to create threads, because stack has no resizable size. If we keep creating threads, we run out of memory quite quickly. Therefore when we run on a machine with 4 cores, ideally we would have only 4 threads. If we use only 1 thread, then 3 cores are doing nothing. On the other hand, when we have thousands of threads, there will be some overhead happening in our application due to context switching. Basically when a scheduler switches execution from one thread to another, it needs to store some data associated with that thread and a large amount of memory needs to be moved around.

However, the problem with having as many threads as cores is with the blocking operations. If we have 4 threads, and all of them are waiting for some API response, then again our program is busy doing nothing and the app becomes unresponsive.

Since starting a thread is an expensive operation and we cannot scale threads to infinite numbers (max app 10k) we use  thread pools. Typically, in applications there is a thread pool for blocking operations (many threads, unbounded) and another one for async operations where the number of threads equals the number of cores.

ZIO ships with lightweight green threads called fibers. All ZIO code is executed on fibers. They are very cheap. They don’t map to OS level threads. We can have millions of them without worrying about the overhead of context switching. Fibers offer an amazing performance. In addition,  if we’re not sure if the operation that we invoke is blocking or not, ZIO can be smart about it and figure it out for us and ship execution to blocking or a non-blocking thread pool automatically. 

To create a fiber, we call the .fork method. Then we can await fiber execution or interrupt the fiber and ZIO will take care of cleaning up resources. 

for {
     fiber <- Console.printLine(".").forever.fork
     _     <- ZIO.sleep(10.millis)
     _     <- fiber.interrupt
   } yield ()

In this example, we are writing dot to the console forever. We forked that fiber, slept for 10 milliseconds and interrupted the fiber. If we need to debug fibers, there are dump and trace methods available for us.

Robustment

In our programs we can encounter two kinds of errors – recoverable and non-recoverable errors.

Now, would you say that SQLException is a recoverable error? It’s definitely not a fatal error like OutOfMemoryError but still I would say that it depends on the layer of our application and where we encounter it. 

That’s why I consider Java’s checked exceptions to be a failed experiment. Not only don’t they compose in higher order functions, but also we need to treat the errors differently depending on the layer where we encounter them. In the end, the best practice in Java is to catch a checked exception and throw the unchecked one.

To follow the example with SQLException, here we have a method that would execute a query on sql connection in order to get ResultSet. This is kind of a low level method usually done in the libraries. Lots of stuff that we encounter in the java.sql package can throw SQLException and we can handle it.

trait QueryExecutor {
   def executeQuery(query: String): IO[SQLException, ResultSet] = ???
}

On the other hand, let’s imagine we are building a repository to retrieve Customer from DB.

trait CustomerRepository {
   def findById(id: String): IO[Nothing, Option[Customer]]
}

Here our error is Nothing,  not because our method cannot fail but because in this layer there is nothing we can do about it. The only thing the caller cares about or can handle is whether the Customer exists in the database or not, and that is represented by the use of Option here.

ZIO introduces an error channel which is designed to handle only recoverable errors. It treats the errors as first class values and stores them in the error channel. 

We cannot recover from defects, so don’t ever do the following:

def parse(lines: String): IO[NullPointerException, String] = ???

NullPointerException should not happen in the first place. If it did, let it propagate and trust ZIO’s support for a lossless error model to be propagated in logs and metrics. At the edge of our system, we can then still sandbox all of our errors. 

Typed error channel is something new for the whole community and we are still discovering best practices  but if you have ever enjoyed statically typed languages, then typed error channel makes sense.

Resiliency

With ZIO it is easy to be responsive to failures. We can catch all errors, catch some and forget about the others, write a fallback or retry. 

openFile("primary.data")
     .retryOrElse(Schedule.recurs(5), (_, _) => ZIO.succeed(DefaultData))

We try to read from the file. If it fails, we retry 5 times. If all of the retries fail, we provide some default data.

Logging & Metrics

ZIO has a built in support for logging and metrics. 

Many of us have used Mapped Diagnostic Context (MDC) which is built on ThreadLocal under the hood. It allows us to stamp some thread with a value, like user id or tracing id. That way, when a request comes in and one thread is handling it, all of the logs written by that thread have access to that id. This makes analytics and diagnostic issues very simple. However, ZIO has a concurrency model based on fibers. Fortunately, logging in ZIO is fiber aware. We can use MDC – called log annotations – or print the fiber that logged a message.

With ZIO metrics we can capture counter, gauge, histogram, summary, frequency, and therefore get an insight into what is happening across our services and export these data to monitoring systems.

Resource safety

With ZIO, we can build applications that never leak resources even in the case of failure or interruption. Proper handling of resources can maximize latency and maximize node uptime.

 def readFile(file: String) =
   ZIO.acquireReleaseWith(ZIO.attempt(Source.fromFile(file)))(bs => ZIO.succeed(bs.close())){ source =>
     ZIO.attempt(source.getLines().mkString("\n"))
   }

Another useful operator is addFinalizer,  which requires a so-called Scope in our environment. 

def fileSource(name: String): ZIO[Scope, Throwable, Source] =
   for {
       source  <- openFile(name)
       _       <- ZIO.addFinalizer(closeFile(source))
   } yield source

Now we can map, flatMap, zip or do whatever we want with the program and ZIO gives us a guarantee that it will run the finalizer (close the file) once we add Scope to the environment. 

This is useful when we cannot solve our problem with acquireReleaseWith. Let’s say we want to open a bunch of files in parallel, then concat their lines together also in parallel and we want to close the files once all the work is done.

def concatLinesOfFiles(files: Chunk[String]) =
       ZIO.scoped {
         for {
           source <- ZIO.foreachPar(files)(file => fileSource(file))
           iterators <- ZIO.foreachPar(source)(s => ZIO.attempt(s.getLines().toVector))
           iterable = iterators.reduce((l, r) => l.zip(r).map { case(line1, line2) => line1 + line2 })
         } yield iterable.mkString("\n")
     }

As we can see, satisfying Scope in the environment is as easy as calling ZIO.scoped{...}

Global efficiency 

Future in Scala is very inefficient. Not only is it eager, but  it’s also impossible to interrupt a Future. If we race 100 Futures  and we are interested only in the fastest one, all of the non-winning 99 Futures will run to completion, wasting our resources, even though their result is not used and discarded.

ZIO on the other hand is very efficient. It  interrupts not winning fibers. In the following example, a fiber which runs the loadFromCache method will be interrupted because loadFromDB wins the race contest. The Race method returns the first successful effect.

def loadFromCache =
   ZIO.succeed("Loaded from cache!").delay(1.second)

def loadFromDB] =
   ZIO.succeed("Loaded from DB!").delay(500.millis)

val run =
   loadFromCache.race(loadFromDB)

ZIO also introduces a timeout method which returns a new effect with either a Some of successful value or a None if a specified amount of time has elapsed. In that case, the running effect will be safely interrupted and the resources cleaned.

 factorial(5000).timeout(10.seconds).map{
   case Some(result) => ???
   case None         => ???
 }

Testability

ZIO ships with the ZIO Test framework, which helps us effectively test our programs. Other Java or Scala test frameworks are built on some kind of Assertions and Matchers which allow these frameworks to render useful error messages. They come with their own syntax that we need to learn. Let’s see ZIO test in action:

test("hello world failing test") {
    val result = "world"
    assertTrue("hello".contains(result))
}

ZIO Test lets us focus on testing business logic. It introduces the assertTrue method, which accepts a simple Boolean and returns a TestResult. However, assertTrue is not an ordinary method, but a macro that provides meaningful insights into why our test has failed. So when we run the test above, instead of the test failing with expected true, found false the error message that we’ll get is "hello" did not contain "world". This is indeed a very simple example, but the macro works great also for deeply nested structures and turns out to be very helpful.

In general, testing effectful programs is difficult. Modern effectful programs do a lot of input/output, they are concurrent, asynchronous, resourceful, scheduled, retried, cached… So let’s say we want to test the following program which does console IO and sleeps for 10 seconds before writing a greeting response.

 val program = for {
   _    <- Console.printLine("What's your name?")
   name <- Console.readLine
   _    <- ZIO.sleep(10.seconds)
   _    <- Console.print(s"Hello ${name}!")
 } yield ()

In other test frameworks we need to run the IO and then the test would have to deal with a sleeping thread, by awaiting the result – or the tested code would need to be modified. In ZIO Test we can handle this problem by simply adjusting the test clock.

 test("hello world lazy greeting") {
     for {
       _        <- program.fork   // `fork` makes the program to run in the background and not block the test
       _        <- TestConsole.feedLines("Jaro")
       _        <- TestClock.adjust(10.minutes)
       lines 	<- TestConsole.output
       greeting  = lines.toList(1) // to get the second line
     } yield assertTrue(greeting == "Hello Jaro!")
 }	

Architecturally, tests themselves are just values which can be passed around, composed and transformed. We have a lot of test aspects to choose from, e.g. flaky – a test that might fail when run multiple times; ignore – aspects that won’t run the test; sequential – to mark a suite of stateful tests to run in sequence. Test fixtures allow us to execute logic before, after or around all of the tests in the suite for setup, teardown operations. We can also provide shared layers of dependencies for our test. To sum up, ZIO Test provides everything that we would expect from a modern test framework. 

Dependency injection

When it comes to dependency injection (DI), we can take a page out of the modern OOP book, because there’s no reason to change something that works. Coding to interfaces – not to implementation – while using constructor-based dependency injection is also considered best practice in ZIO. We cannot create a service without first creating its dependencies and the service’s dependencies are clearly propagated to the outside world.

However  some DI framework is needed as our service might be resourceful or we need resiliency during the startup process.  It’s also very boilerplate-y to create all services by ourselves.

ZIO shines in this area, as it introduces Layer which is a recipe for creating services. In other words, with Layer we describe the creation of a service and then at the edge of the application, we wire all the layers together. Under the hood, ZIO uses macros to give us automatic wiring of dependencies, a compile time error message if needed and rapid startup time.

Unlike class constructors which block, Layer is fully async and non-blocking. Layers can be acquired in parallel. We can also retry the acquiring stage. If we create something that is resourceful, we can then release resources. For example, a database connection pool would have a finalizer that would close the pool.

Let’s take a look at an example of UserRepository implementation,  which depends on ConnectionPool and UserService implementation, which depends on UserRepository and some counter represented as Ref[Int], which would count the number of invocations of the method.

class UserRepositoryImpl(pool: ConnectionPool) extends UserRepository {
  def findAll(): IO[Nothing, List[User]] = ???
}

class UserServiceImpl(repo: UserRepository, counter: Ref[Int]) extends UserService {
  // some methods
}

Now we need to describe the creation of both services:

object UserRepositoryImpl {
   val live: ZLayer[ConnectionPool, Throwable, CustomerRepository] =
     ZLayer.fromFunction(new CustomerRepositoryImpl(_))
}


object UserServiceImpl {
 val live =
   for {
     repo    <- ZIO.service[UserRepository]
     counter <- Ref.make[Int](0)
   } yield new UserServiceImpl(repo, counter)

The last step is to provide our services to our program.

program
     .provide(
       ConnectionPool.live,
       Config.connectionPoolConfig,
       UserServiceImpl.live,
       UserRepositoryImpl.live
     )

Program is a ZIO value that accesses services from the Environment – which is a type-indexed map that contains all of the services that our application consumes. For brevity, I have omitted ConnectionPool.live and Config.connectionPoolConfig, but the story is the same. Now, if we forget to provide ConnectionPool.live, no worries. Program will fail to compile with a beautiful error message saying that we need to provide a layer for ConnectionPool

And more

There’s so much more to ZIO that we didn’t cover here. Did I mention streaming support? ZStream describes a process that produces zero or more elements, all lazy and possibly never-ending. It has tons of very useful concurrent operators baked in. ZStream is also resource safe, making sure to run all the finalizers when our stream is exhausted. It took engineers years to get such things right and it’s all described in the official documentation. I encourage everyone to go through it, even if you don’t need some specific feature at the moment because it serves as a great learning source. From basic operators to advanced FP techniques, you can find it all there.

Summary

It’s fair to say that not everything is sunshine and roses. In every ecosystem we can find some unideal parts and Scala is no exception. While the language has some amazing features, it lacks good tooling. When using slightly more advanced types, Intellij IDEA starts to show phantom errors, Metals keeps on crushing and compilation times are very long. For me personally, SBT is quite painful to work with. However every obstacle can be overcome and Scala is definitely worth investing our efforts into.

I am persuaded that the best way to build backend applications is to do Functional Programming in Scala using ZIO. It’s not just the syntax, it’s the superpowers that developers can gain. I’ve witnessed Java projects where adding a simple feature with a few lines of code has taken weeks. On the other hand, when some bigger functionality was built quickly, lots of bugs were introduced to production. That is ridiculous, developers can and should do better. With ZIO, the future of programming is here, give it a try :)

With John De Goes’s consent, some code samples were copied from his training materials and ZIO documentation.
If you want to learn more about Scala, ZIO & functional programming you can join one of our online meetups

Download e-book:

Scalac Case Study Book

Download now

Authors

Jaroslav Regec
Jaroslav Regec

I am a Scala developer passionate about Scala language, its expressivity and strong type system. I am big fan of ZIO, functional design techniques, type-safe DSLs and type-level programming in general. In my free time my hobbies are cycling, playing guitar and travelling with my wife.

Latest Blogposts

14.03.2024 / By  Dawid Jóźwiak

Implementing cloud VPN solution using AWS, Linux and WireGuard

Implementing cloud VPN solution using AWS, Linux and WireGuard

What is a VPN, and why is it important? A Virtual Private Network, or VPN in short, is a tunnel which handles all the internet data sent and received between Point A (typically an end-user) and Point B (application, server, or another end-user). This is done with security and privacy in mind, because it effectively […]

07.03.2024 / By  Bartosz Puszczyk

Building application with AI: from concept to prototype.

Blogpost About Building an application with the power of AI.

Introduction – Artificial Intelligence in Application Development When a few years ago the technological world was taken over by the blockchain trend, I must admit that I didn’t hop on that train. I couldn’t see the real value that this technology could bring to someone designing application interfaces. However, when the general public got to […]

28.02.2024 / By  Matylda Kamińska

Scalendar March 2024

scalendar march 2024

Event-driven Newsletter In the rapidly evolving world of software development, staying in line with the latest trends, technologies, and community gatherings is crucial for professionals seeking to enhance their skills and network. Scalendar serves as your comprehensive guide to navigate events scheduled worldwide, from specialized Scala conferences in March 2024 to broader gatherings on software […]

software product development

Need a successful project?

Estimate project