Skip to Content
Course content

156: Building a GraphQL Server with Caliban

Click on the "Edit" button in the top corner of the screen to edit your slide content.

By now, you're probably used to REST, where you have one endpoint per resource. But GraphQL is a different beast. In the Scala ecosystem, Caliban is the gold standard because it's "code-first"—meaning you define your Scala types, and Caliban derives the GraphQL schema for you. No more jumping back and forth between a .graphqls file and your source code.

How do I actually map my Scala cases to a GraphQL schema?

In Caliban, your schema is essentially a reflection of your Scala types. You start by defining your domain models as case classes and then create a RootResolver. This resolver acts as the entry point for your queries, mutations, and subscriptions.

Let's say we're building a backend for a boutique coffee shop. We have Coffee beans and Orders. Here is how I'd set up the basic structure:

case class Coffee(id: String, name: String, roast: String, price: Double)
case class Order(id: String, coffeeId: String, quantity: Int)

// A mock database
val coffees = Map("1" -> Coffee("1", "Ethiopian Yirgacheffe", "Light", 18.0), 
                  "2" -> Coffee("2", "Sumatra Mandheling", "Dark", 16.0))
val orders = List(Order("101", "1", 2), Order("102", "2", 1))

// The RootResolver defines what the client can ask for
val rootResolver = RootResolver(
  Query.root(
    // Define a field 'coffee' that takes an ID and returns a Coffee
    Field("coffee", ZIO.attempt(coffees.getOption(_).getOrElse(throw new Exception("Not found"))), "Fetch coffee by ID"),
    // Define a field 'allCoffees' that returns a list
    Field("allCoffees", ZIO.attempt(coffees.values.toList), "List all available coffees")
  )
)

val api = GraphQL.schema(rootResolver)

Notice that I'm using ZIO here. Caliban is built on ZIO, which is a huge advantage. It means your resolvers are asynchronous and type-safe by default, which is exactly what you want when you're hitting a database or an external API.

What if I need to fetch related data, like getting the Coffee details for an Order?

This is where a lot of people get stuck. If you just put a coffeeId in your Order class, the client has to make two requests: one for the order and another for the coffee. That's just REST with extra steps. We want the client to be able to query { order { coffee { name } } }.

To do this, you don't put the Coffee object directly inside the Order case class. Instead, you define a "resolver" for that specific field. I prefer this approach because it prevents you from over-fetching data if the client doesn't actually ask for the coffee details.

case class Order(id: String, coffeeId: String, quantity: Int)

// We extend the Order type in the GraphQL schema
val orderResolver = Order.field("coffee", (order: Order) => 
  ZIO.attempt(coffees.get(order.coffeeId)).map(_.getOrElse(throw new Exception("Missing coffee")))
)

// Now we apply this resolver to our RootResolver
val rootResolver = RootResolver(
  Query.root(
    Field("orders", ZIO.attempt(orders), "Get all orders")
  ),
  // This tells Caliban: "Whenever you encounter an Order, use this logic for its fields"
  Order.resolvers(orderResolver)
)

One quick tip: Be careful with the "N+1 problem" here. If you fetch 100 orders and then fetch the coffee for each one individually, you've just hit your database 101 times. In a real production app, I'd recommend using Caliban's DataLoader to batch these requests into a single query.

How do I actually serve this schema over HTTP?

Defining the schema is the easy part; making it reachable is where the plumbing comes in. Caliban doesn't force a specific HTTP server on you, but it provides an interpreter that converts a GraphQL request into a ZIO effect.

If you're using caliban-http with something like ZIO Http or Http4s, it looks something like this:

import caliban.execution.GraphQLInterpreter
import caliban.pli.GraphiQL

val interpreter = GraphQLInterpreter(api)

// This creates a handler that takes a request and returns a response
val handler = interpreter.handler 

// I also highly recommend adding GraphiQL in development. 
// It's that interactive browser IDE that lets you test queries.
val graphiQLHandler = GraphiQL.handler

I usually set up two routes: one for the actual API /graphql and one for the IDE /graphiql. It makes debugging your resolvers ten times faster than using curl or Postman.




📋 Practical Task

Implement a "Total Price" Calculated Field for Coffee Orders

You have the existing Coffee Shop system. Your task is to extend the Order resolver to include a calculated field called totalPrice.

  • Modify the Order resolver so that when a user queries totalPrice, the server calculates Order.quantity * Coffee.price.
  • You will need to look up the Coffee object using the coffeeId from the Order and then perform the multiplication.
  • Ensure the field returns a Double.
  • Test your implementation by running a query that requests both the quantity and the totalPrice for a specific order.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.