Sangria is a GraphQL implementation for Scala. You describe your API as a schema of typed objects and fields, each field carries a resolver function, and Sangria parses incoming queries, validates them against the schema, runs the resolvers and assembles the JSON response. It runs on plain scala.concurrent.Future, so it fits naturally into Akka HTTP, Play or http4s services that already use Futures.

The library has been around since the early days of GraphQL in Scala and is now maintained by the sangria-graphql organisation. The 4.x line is published for Scala 2.12, 2.13 and 3. This article explains how Sangria executes a query, how to write a schema that stays fast under real traffic, and how to stop one bad query from taking your server down. The examples use a small library catalogue with books and authors.

Advertisement

The execution pipeline

Before writing any schema code it helps to know what happens to a request. GraphQL clients send a query string, an optional operation name and a JSON object of variables. Sangria turns that into a response in five stages, shown in the diagram.

One GraphQL request through Sangria: every stage can reject the query before any resolver runsHTTP layerhttp4s / Akka HTTPQueryParserString to ASTQueryValidatorrules vs schemaQueryReducerscomplexity, depthExecutorwalks selection setField resolversContext[Ctx, Val]DeferredResolverFetchers batch idsDeferred valuesMiddlewaretiming, tracingRepositoriesDB, services1 batched callResultMarshallercirce, play-jsonJSON data + errorsExceptionHandler maps thrown errorsto GraphQL error entries
Parsing, validation and query reduction happen before any resolver runs, so they are where you reject bad or expensive queries cheaply. Resolvers may return plain values, Futures or Deferred values that a DeferredResolver batches.
  1. Parse. QueryParser.parse turns the string into a Document AST and returns a Try. A syntax error never reaches your code.
  2. Validate. The query is checked against the schema: fields exist, argument types match, fragments are used correctly. This is part of Executor.execute.
  3. Reduce. Query reducers walk the validated AST and compute values such as complexity or depth. A reducer can reject the query outright.
  4. Execute. The executor walks the selection set, calling each field's resolver with a Context holding the parent value, the arguments and your user context.
  5. Marshal. A result marshaller, for example the circe one, builds the output JSON with data and errors.

Ctx and Val: the two type parameters

Every Sangria field is typed with two parameters. Ctx is the user context: one object per request that holds your repositories, the authenticated user and anything else resolvers need. Val is the value of the parent object the field is resolved on. For a field on the Book type, Val is a Book instance and the resolver reads from ctx.value.

This makes data flow explicit. The root query type usually has Val = Unit, and each nested type receives whatever its parent resolver returned. Because Ctx is created per request, it is the right place for request-scoped state such as the caller's identity; it is the wrong place for anything shared across requests that is not thread-safe.

import sangria.schema._
import scala.concurrent.Future

case class Author(id: String, name: String)
case class Book(id: String, title: String, authorId: String)

trait LibraryRepo {
  def book(id: String): Future[Option[Book]]
  def books(limit: Int): Future[Seq[Book]]
  def authorsByIds(ids: Seq[String]): Future[Seq[Author]]
}

case class Ctx(repo: LibraryRepo, userId: Option[String])

val AuthorType = ObjectType("Author", fields[Ctx, Author](
  Field("id", IDType, resolve = _.value.id),
  Field("name", StringType, resolve = _.value.name)
))
Advertisement

Arguments, nested types and the query root

Arguments are declared once and read with ctx.arg, which returns a value of the right Scala type. Nullable fields use OptionType and lists use ListType. A field may return a plain value, a Future or a Deferred value; Sangria handles all three through implicit conversions in its Action type.

val IdArg    = Argument("id", IDType)
val LimitArg = Argument("limit", OptionInputType(IntType), defaultValue = 20)

lazy val BookType: ObjectType[Ctx, Book] = ObjectType("Book", () => fields[Ctx, Book](
  Field("id", IDType, resolve = _.value.id),
  Field("title", StringType, resolve = _.value.title),
  Field("author", OptionType(AuthorType),
    resolve = c => authorFetcher.deferOpt(c.value.authorId))
))

val QueryType = ObjectType("Query", fields[Ctx, Unit](
  Field("book", OptionType(BookType), arguments = IdArg :: Nil,
    resolve = c => c.ctx.repo.book(c.arg(IdArg))),
  Field("books", ListType(BookType), arguments = LimitArg :: Nil,
    resolve = c => c.ctx.repo.books(math.min(c.arg(LimitArg), 100)))
))

val LibrarySchema = Schema(QueryType)

Two details matter here. The () => thunk around the field list lets types refer to each other recursively without initialisation-order problems. And the books resolver clamps limit to 100 on the server; never trust a client-supplied page size.

For simple case classes you can skip hand-written fields. Sangria's derivation macros (the sangria-derivation module in the 4.x line) generate an ObjectType from a case class, with options such as ObjectTypeName, RenameField and ExcludeFields. Derivation is convenient for flat data types; hand-written fields are clearer wherever a field needs a resolver that does I/O.

Executing a request

The executor needs the schema, the parsed document, the user context, the variables and an implicit ExecutionContext plus a marshaller for the input and output format. The handler below is framework-agnostic: it takes the three parts of a GraphQL POST body and returns a status code and JSON. Plug it into whichever HTTP library you use.

import io.circe.Json
import sangria.execution._
import sangria.marshalling.circe._
import sangria.parser.QueryParser
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}

def handle(query: String, op: Option[String], vars: Json, ctx: Ctx)
          (implicit ec: ExecutionContext): Future[(Int, Json)] =
  QueryParser.parse(query) match {
    case Failure(e) =>
      Future.successful(400 -> Json.obj("errors" ->
        Json.arr(Json.obj("message" -> Json.fromString(e.getMessage)))))
    case Success(doc) =>
      Executor.execute(LibrarySchema, doc, ctx,
          variables = if (vars.isNull) Json.obj() else vars,
          operationName = op,
          deferredResolver = DeferredResolver.fetchers(authorFetcher),
          queryReducers = limits,
          exceptionHandler = errors)
        .map(200 -> _)
        .recover {
          case e: QueryAnalysisError => 400 -> e.resolveError
          case e: ErrorWithResolver  => 500 -> e.resolveError
        }
  }

The recover block distinguishes the two families of failure. A QueryAnalysisError means the client sent something invalid, such as an unknown field or a query that a reducer rejected, so it maps to 400. An ErrorWithResolver is a server-side failure outside normal field resolution. Both can render themselves as GraphQL error JSON through resolveError. Errors thrown inside individual resolvers do not fail the whole request; they become entries in the errors array next to partial data.

Pass the ExecutionContext deliberately. If your repositories make blocking JDBC calls, run them on a dedicated pool rather than the global one, for the reasons covered in Scala Futures and ExecutionContext.

Fetchers: solving N+1 with a worked example

The most common performance problem in any GraphQL server is N+1 loading. Consider this query:

{ books(limit: 50) { title author { name } } }

With a naive author resolver that calls the database once per book, the server makes 1 query for the books and 50 queries for authors: 51 round trips. If each round trip costs 2 ms, the request spends over 100 ms waiting on the database even though only two queries were logically needed.

Sangria's answer is the Fetcher. A resolver returns a Deferred value instead of a Future. The executor collects every deferred value produced at the same level of the query, hands the full set of ids to the DeferredResolver, and the fetcher loads them in a single call.

import sangria.execution.deferred.{DeferredResolver, Fetcher, HasId}

implicit val authorHasId: HasId[Author, String] = HasId(_.id)

val authorFetcher = Fetcher(
  (ctx: Ctx, ids: Seq[String]) => ctx.repo.authorsByIds(ids))

Now the same query costs two round trips: one for the books and one WHERE id IN (...) for at most 50 distinct authors, so roughly 4 ms of database time instead of 100. The repository must return authors for the requested ids in any order; HasId is how Sangria matches them back to each book. If an author is missing, deferOpt yields null for that field, while defer would produce an error.

Fetchers can also cache within a request (FetcherConfig.caching), which helps when the same id appears at several depths of one query. Keep the cache request-scoped: a cache shared across requests turns into a stale-data and memory problem, and belongs in your repository layer, not in GraphQL.

Protecting the server: complexity, depth and errors

GraphQL lets the client choose the shape of the work. A query that nests books { author { books { author ... } } } ten levels deep, or that asks for 100 books with 100 reviews each, can cost thousands of times more than a normal request. Because reducers run after validation and before execution, they are the cheap place to say no.

val limits = List(
  QueryReducer.rejectComplexQueries[Ctx](1000,
    (complexity, _) => new IllegalArgumentException(
      s"Query complexity $complexity exceeds 1000")),
  QueryReducer.rejectMaxDepth[Ctx](10)
)

case class NotAuthorised(msg: String) extends Exception(msg)

val errors = ExceptionHandler {
  case (_, e: IllegalArgumentException) => HandledException(e.getMessage)
  case (_, e: NotAuthorised)            => HandledException("Not authorised")
}

By default every field adds a complexity of 1 plus the complexity of its children. That undercounts list fields, so give expensive fields a complexity function on the Field that multiplies the child cost by the requested limit. Choose the threshold from data: log the computed complexity of real production queries for a week, then set the limit comfortably above the largest legitimate one.

The ExceptionHandler decides what clients see. Map the exceptions you expect, such as validation or authorisation errors, to HandledException with a safe message. Anything unmapped is reported as an internal error, so stack traces and SQL fragments do not leak into responses. Add persisted queries or an allow-list for public APIs where you control all clients; it is a stronger defence than any complexity limit.

Middleware, mutations and subscriptions

Middleware hooks into query and field execution. A class that extends Middleware[Ctx] gets beforeQuery and afterQuery callbacks; mixing in MiddlewareAfterField adds per-field beforeField and afterField. Use it for per-field timing metrics, tracing spans and authorisation checks driven by field tags. Keep per-field work tiny: it runs for every field of every request, and a query returning 5,000 values calls it 5,000 times.

Mutations are another ObjectType, passed as the second argument of Schema. GraphQL executes top-level mutation fields one after another, so ordering within a request is predictable; each mutation resolver should still be idempotent where it can be, because clients retry on timeouts. Subscriptions are supported through stream integrations, for example with Akka Streams; check the integration module for your stack and version before committing to them.

Failure modes in production

  • N+1 creeping back. A new field that calls the repository directly instead of a fetcher. Add a test that counts repository calls for a nested query.
  • Blocking the executor. JDBC calls on the default pool starve every other request. Use a bounded blocking pool.
  • Unbounded lists. List fields without a server-side maximum let one query read a table. Clamp limits and price them in complexity.
  • Introspection in public. Introspection reveals your full schema. Decide deliberately whether public clients may use it.
  • Leaky errors. Unhandled exceptions with database text. Map known errors and log the rest with a request id.
  • Schema drift. Removing or renaming a field breaks clients. Mark fields deprecated first, watch usage, then remove them.

Trade-offs: Sangria, Caliban or REST

Sangria's explicit schema DSL is verbose, but it makes every resolver and every type visible in one place, and it works with any Future-based stack. Caliban derives the schema from Scala types and runs on ZIO with built-in batching through ZQuery; it is the more natural choice for a ZIO codebase or a team that wants less boilerplate. For APIs with a few fixed clients and simple resources, typed REST endpoints with Tapir are simpler to cache, rate-limit and monitor than any GraphQL server.

Pick Sangria when you have a Future-based service, many clients that need different shapes of the same data, and a team willing to own query cost controls. Serve it through http4s or Akka HTTP with circe for JSON.

What to do next

  1. Add Sangria and sangria-circe to a service and expose one read-only query type behind a single POST endpoint.
  2. Write a per-request Ctx that carries the repositories and the caller's identity.
  3. Replace every per-parent repository call with a Fetcher and add a test that asserts the number of database calls for a nested query.
  4. Clamp every list argument on the server and assign list fields a complexity that scales with the limit.
  5. Log query complexity and depth for real traffic, then enable rejectComplexQueries and rejectMaxDepth with thresholds from that data.
  6. Map expected exceptions in an ExceptionHandler and confirm unexpected ones return no internal details.
  7. Add field-level timing middleware and alert on the slowest resolvers.
Key takeaway: Sangria turns an explicit Scala schema into a GraphQL server running on Futures. Requests go through parse, validate, reduce, execute and marshal, and the first three stages are where you reject bad or expensive queries cheaply. Use a per-request Ctx, return Deferred values through Fetchers to collapse N+1 loads into batched calls, clamp lists and enforce complexity and depth limits, and map errors deliberately so clients get useful messages and never internal details.