GraphQL in Scala: Handling side effects

GraphQL in Scala: Handling side effects

Dealing with async computations and side effects with Caliban

In my Beginner's Guide to GraphQL in Scala, I created a simplistic resolver that just returned data from an immutable value loaded in memory. In real-life use cases, things are usually quite different: data may come from a database, external APIs, or various other locations. We might need to call functions that return Future, Monix Task, ZIO, or even an abstract F[_] with cats-effect typeclasses. In this article, we will see how to handle various types of effects in our resolvers and examine their specificities.

All code snippets can be reproduced in Scala CLI (using Scala 3, which is the default) by adding the following directive and imports (when more imports are required, they will be added to individual snippets):

//> using dep com.github.ghostdogpr::caliban-quick:2.5.1
import caliban.*
import caliban.schema.*

Dealing with impure functions and Futures

In functional programming, a function is considered pure if:

  • it is deterministic: the same input will always give the same output

  • the output depends only on the input and the function implementation (it does not depend on some hidden context)

  • it computes an output and does not have side effects (it does not modify the world around it)

Let's take a simple example to illustrate a problem that occurs when you try to use impure functions in your resolvers.

import scala.util.Random

case class Query(number: Int) derives Schema.SemiAuto

println(render[Query])
// type Query {
//   number: Int!
// }

val random   = new Random()
val resolver = Query(number = random.nextInt)
val api      = graphQL(RootResolver(resolver))

import caliban.quick.*

api.unsafe.runServer(8088, "/api/graphql")

This defines a simple schema with a single field number that returns an Integer coming from the impure function Random#nextInt (this function is impure because it is not deterministic: each execution will give a different result).

If you try to run this example and send the GraphQL query { number } several times to the API, you will notice that you always receive the same result, which is unexpected. That actually makes sense if you look at the example in detail: we create a single resolver value, which means there will be a single call to random.nextInt and this call will happen when we create resolver, not when a query is executed. That explains why it always returns the same value.

The solution to this issue is to make the field lazy. By lazy, we mean that we don't want the field to be executed too quickly; instead, we want it to be executed each time we actually resolve our field. To do that, we need to change the field type from an Int to a function that returns an Int. Since a function requires an input type, but we don't need any input here, we will use Unit as an input.

import scala.util.Random

case class Query(number: () => Int) derives Schema.SemiAuto

println(render[Query])
// type Query {
//   number: Int!
// }

val random   = new Random()
val resolver = Query(number = () => random.nextInt)
val api      = graphQL(RootResolver(resolver))

import caliban.quick.*

api.unsafe.runServer(8088, "/api/graphql")

We've just made two changes: number is now of type () => Int, and the number field value has been updated accordingly from random.nextInt to () => random.nextInt. Notice how our GraphQL schema remains the same: () => Int and Int produce the same output type Int! during schema derivation. With that value being a function, it will not run when resolver is created, but each time the field is resolved instead.

What if we wanted to run an asynchronous computation? In Scala, one solution included in the standard library is the Future data type. For this example, let's wrap our random call inside a Future.

import scala.concurrent.Future
import scala.util.Random
import concurrent.ExecutionContext.Implicits.global

case class Query(number: Future[Int]) derives Schema.SemiAuto

val resolver = Query(number = Future { random.nextInt })

Try running the same example and you will see that we're back to the original problem: we always receive the same result. The reason is that Scala's Future is not lazy but eager: the computation starts as soon as you create the Future, and the result is memoized so that each time you use the same Future you will get the same result.

What if we must use Future then? Maybe our database library forces us to do so. In that case, we can use the exact same "laziness" technique and transform our field into a function from Unit to a Future.

case class Query(number: () => Future[Int]) derives Schema.SemiAuto

val resolver = Query(number = () => Future { random.nextInt })

That did the trick! Note that when your field uses arguments, it is already a function so there's no need to use Unit.

If you tried this on your own, you might have noticed a slight difference between the Future case and the previous "raw" example.

println(render[Query])
// type Query {
//   number: Int
// }

In the GraphQL schema, the type of number changed from a non-nullable Int! to a nullable Int. Why? The main difference when using Future is that a Future might fail (see the Future.failed constructor), so there may be an error while we try to resolve our field.

In GraphQL, there are two ways errors can be handled:

  • If an error occurs on a nullable field, the field returns null and the error is included in the response in an errors field.

  • If an error occurs on a non-nullable field, it is propagated to the parent field, where the same process is repeated until the root.

In Caliban, we've chosen to implement the first behavior by default: if a field returns a Future, it becomes nullable so that any error will be reported at that field level. If you want to control nullability explicitly, you can use the recently-added (in 2.5.1) annotations @GQLNullable and @GQLNonNullable on your fields, but a better option might be to switch to an effect type that is more powerful, for example ZIO.

Here's what you will get if our Future returns an error:

{
  "data": {
    "number": null
  },
  "errors": [
    {
      "message": "Effect failure",
      "locations": [
        {
          "line": 1,
          "column": 3
        }
      ],
      "path": [
        "number"
      ]
    }
  ]
}

Introducing lazy ZIO effects

ZIO is a library for type-safe, composable asynchronous and concurrent programming in Scala, and offers an interesting alternative to Future. If you are not familiar with ZIO, think of it as a Future with these major key differences (ZIO has a lot more to it, but I will only focus on what matters in the context of this article):

  • ZIO is truly lazy: unlike eager Future, creating a ZIO value will not start any computation. That means you can reuse a single ZIO and run it multiple times.

  • ZIO has 3 type parameters: ZIO[R, E, A]. A ZIO may require a context R to run, might fail with an error of type E, or succeed with a result of type A. You can think of it as R => Future[Either[E, A]] baked into a single type.

One more thing to know before going further is that ZIO comes with a few aliases to use fewer type parameters in simpler cases:

  • UIO[A] is a ZIO that does not require any context (in that case R is Any) and cannot fail (E is Nothing).

  • Task[A] is a ZIO that does not require any context and can fail with a Throwable. This is quite close to Future[A], but lazy.

  • IO[E, A] is a ZIO that does not require any context and can fail with an error of type E.

The laziness of ZIO means that we no longer have issues with resolvers being executed too early: you can directly use it without using the () => trick we've seen above.

import zio.*

case class Query(number: UIO[Int]) derives Schema.SemiAuto

println(render[Query])
// type Query {
//   number: Int!
// }

val resolver = Query(number = Random.nextInt)
val api      = graphQL(RootResolver(resolver))

import caliban.quick.*

api.unsafe.runServer(8088, "/api/graphql")

That's it! We used the built-in function Random.nextInt to get a random number and received a UIO[Int] in return, meaning that this function doesn't require any context and that it cannot fail. Since it cannot fail, our field is non-nullable in our schema.

If we wanted to have the field nullable, we could simply change it to Task.

case class Query(number: Task[Int]) derives Schema.SemiAuto

println(render[Query])
// type Query {
//   number: Int
// }

Because Task might fail, we're back to a nullable Int. With ZIO, you can easily transform a Task to a UIO using the function .orDie. This is possible because ZIO has two error channels: expected errors, expressed by the E type parameter, and unexpected errors, which are not present in the type signature but will fail the computation nonetheless. This makes it easy to configure the nullability of your fields when defining a schema using ZIO. If you use orDie and fail on a non-nullable field, the error will be propagated to the parent field.

Remember the Any in Schema[Any, A] from my previous article on schema generation? This comes from the R type parameter of ZIO. If you use a ZIO that requires a context R, you will need a Schema for that R as well, and the resulting GraphQL object too. Let's look at an example.

import zio.*

case class Config(value: Int)
case class Query(number: ZIO[Config, Nothing, Int])

given Schema[Config, Query] = Schema.gen

println(renderWith[Config, Query])
// type Query {
//   number: Int!
// }

val resolver = Query(number = ZIO.serviceWith[Config](_.value))
val api: GraphQL[Config] = graphQL(RootResolver(resolver))

This time, our field number returns type ZIO[Config, Nothing, Int]. That means we need a Config in order to run it. The implementation of the field uses ZIO.serviceWith[Config] to access that contextual Config and get the value out of it. Another noticeable difference is that we are using renderWith, a function that allows specifying a given R (previously we used the simpler render that works when R is Any). Note how api is of type GraphQL[Config] now instead of GraphQL[Any] as in all previous examples.

In order to run our server, we first need to provide a value for Config, otherwise our code will not compile. This can be done using provideLayer and creating a simple layer with ZLayer.succeed. ZLayer is a type designed to assemble inter-connected dependencies in a type-safe and convenient manner. I am not going to cover it in detail here, but you can refer to the ZIO documentation for more information.

import caliban.quick.*

api
  .unsafe
  .provideLayer(ZLayer.succeed(Config(3)))
  .runServer(8088, "/api/graphql")

Once our Config is provided, the code compiles and produces the expected result. In a typical application, Config would come from parsing configuration files. The context R can be particularly useful in Caliban when you want to access data in your resolver that comes from the user request, such as access tokens or user context. It is possible to provide that environment on each request, as we will see in a future article.

Finally, it is good to know that Caliban uses ZIO under the hood, so regardless of whether you use Future, Monix, ZIO, or Cats Effect, effects are eventually going to be converted to ZIO. That means that you will get the best performance if you use ZIO directly in your schemas. However, the performance loss will usually not be noticeable at all, so it is completely fine to use whatever you are most comfortable with.

Interoperability with Cats Effect

Cats Effect is another library for building concurrent and asynchronous applications, offering an IO type that is very similar to ZIO's Task alias: it is lazy, may fail with a Throwable, or succeed with a given type.

Cats Effect also provides a set of type classes that you can use if you don't want your code to use an explicit IO type, but instead an abstract type F[_] with various laws. Using an interop module provided by Caliban (caliban-cats), it is possible to build your schema using an abstract F[_] (or directly IO) with a few modifications.

Let's rewrite the same Random example with an abstract F[_].

//> using dep com.github.ghostdogpr::caliban-cats:2.5.1

import caliban.interop.cats.implicits.*
import cats.effect.std.{Dispatcher, Random}

def api[F[_]](using Dispatcher[F], Random[F]): GraphQL[Any] = {
  case class Query(number: F[Int]) derives Schema.SemiAuto

  println(render[Query])
  // type Query {
  //   number: Int!
  // }

  graphQL(RootResolver(Query(number = Random[F].nextInt)))
}

There are a couple of things required to make this example compile:

  • For our schema derivation to work, we need a Dispatcher[F] in scope. This will be used to eventually run our effect F and convert it into a ZIO (used under the hood).

  • We also need to import caliban.interop.cats.implicits.* because it contains the necessary implicits for deriving our Schema. This is available in the caliban-cats dependency.

This snippet gives us an API that can be used with any effect F[_] as long as we have a Dispatcher and a Random for it.

Running the example requires more machinery, as we need to create the Dispatcher and the Random instances. We will use cats.effect.IO as our concrete effect type.


import cats.effect.IO
import cats.effect.unsafe.implicits.global

Dispatcher
  .parallel[IO]
  .use { case given Dispatcher[IO] =>
    Random.scalaUtilRandom[IO].flatMap { case given Random[IO] =>
      IO.blocking {
        import caliban.quick.*
        api[IO].unsafe.runServer(8088, "/api/graphql")
      }
    }
  }
  .unsafeRunSync()

First, we create our Dispatcher and Random instances using utility functions from cats effect, and use case given so that they can be in scope for our using in api. Then, we wrap our runServer call inside IO.blocking because the execution is going to block the current thread.

Finally, we get an IO as a response, so we also need to call unsafeRunSync to run the whole program (we could also have used IOApp). This might look a little cryptic if you're not familiar with this style of programming, but if you are, this should be pretty standard: at the end of the day, the only things you need are a special import and a Dispatcher in scope.

Conclusion

In this article, we delved into handling side effects in resolvers when using Caliban to create GraphQL APIs in Scala. We explored how to deal with impure functions, asynchronous computations, and different types of effects such as Future, ZIO, and Cats Effect. This should allow you to create resolvers regardless of the libraries you are using in your business logic and whatever your programming style is.

Having introduced the foundations of ZIO will be useful for future blog posts, where I will look into how you can customize the behavior of your API by running additional computations at various steps of the query processing or by extracting and propagating some contextual data from client requests.