Initial Schema

At this stage, we’re still just taking baby steps, and getting our bearings.

By the end of this stage, we’ll have a minimal schema and be able to execute our first query.

Schema EDN File

We’re going to define an initial schema for our application that matches the domain.

Our initial schema is just for the BoardGame entity, and a single operation to retrieve a game by its id:

resources/cgg-schema.edn
{:objects
 {:BoardGame
  {:description "A physical or virtual board game."
   :fields
   {:id          {:type (non-null ID)}
    :name        {:type (non-null String)}
    :summary     {:type        String
                  :description "A one-line summary of the game."}
    :description {:type        String
                  :description "A long-form description of the game."}
    :minPlayers  {:type        Int
                  :description "The minimum number of players the game supports."}
    :maxPlayers  {:type        Int
                  :description "The maximum number of players the game supports."}
    :playTime    {:type        Int
                  :description "Play time, in minutes, for a typical game."}}}

  :Query
  {:fields
   {:gameById
    {:type        :BoardGame
     :description "Access a BoardGame by its unique id, if it exists."
     :args
     {:id {:type ID}}}}}}}

A Lacinia schema is an EDN file. It is a map of maps; the top level keys identify the type of definition: :objects, :interfaces, :enums, and so forth. Each of these top level keys defines its own structure for the map it contains.

Query is a special object whose fields define the GraphQL queries that a client can execute. This schema defines a single query, gameById, that returns an object as defined by the BoardGame type.

A schema is declarative: it defines what operations are possible, and what types and fields exist, but has nothing to say about where any of the data comes from. In fact, Lacinia has no opinion about that either! GraphQL is a contract between a consumer and a provider for how to request and present data - it’s not any form of database layer, object relational mapper, or anything similar.

Instead, Lacinia handles the parsing of a client query, and guides the execution of that query, ultimately invoking application-specific callback hooks: field resolvers. Field resolvers are the only source of actual data. Ultimately, field resolvers are simple Clojure functions, but those can’t, and shouldn’t, be expressed inside an EDN file.

Later we’ll see how to connect fields, such as gameById to a field resolver.

We’ve made liberal use of the :description property in the schema. These descriptions are intended for developers who will make use of your GraphQL interface. Descriptions are the equivalent of docstrings on Clojure functions, and we’ll see them show up later when we discuss GraphiQL. It’s an excellent habit to add descriptions early, rather than try and go back and add them in later.

We’ll add more fields, more object types, relationships between types, and more operations in later chapters.

We’ve also demonstrated the use of a few Lacinia conventions in our schema:

  • Built-in scalar types, such as ID, String, and Int are referenced as symbols. [1]
  • Schema-defined types, such as :BoardGame, are referenced as keywords.
  • Fields are lower-case names, and types are CamelCase.

In addition, all GraphQL names (for fields, types, and so forth) must contain only alphanumerics and the underscore. The dash character is, unfortunately, not allowed. If we tried to name the query query-by-id, Lacinia would throw a clojure.spec validation exception when we attempted to compile the schema. [2]

In Lacinia, there are base types, such as String and :BoardGame, and wrapped types, such as (non-null String). The two wrappers are non-null (a value must be present) and list (the type is a list of values, not a single value). These can even be combined!

Notice that the return type of the gameByID query is :BoardGame and not (non-null :BoardGame). This is because we can’t guarantee that a game can be resolved, if the id provided in the client query fails to match a game in our database. If the client provides an invalid id, then the result will be nil, and that’s not considered an error.

In any case, this single BoardGame entity is a good starting point.

schema namespace

With the schema defined, the next step is to write code to load the schema into memory, and make it operational for queries:

src/my/clojure_game_geek/schema.clj
(ns my.clojure-game-geek.schema
  "Contains custom resolvers and a function to provide the full schema."
  (:require [clojure.java.io :as io]
            [com.walmartlabs.lacinia.util :as util]
            [com.walmartlabs.lacinia.schema :as schema]
            [clojure.edn :as edn]))

(defn resolver-map
  []
  {:Query/gameById (fn [context args value]
                     nil)})

(defn load-schema
  []
  (-> (io/resource "cgg-schema.edn")
    slurp
    edn/read-string
    (util/inject-resolvers (resolver-map))
    schema/compile))

This code loads the schema EDN file, injects field resolvers into the schema, then compiles the schema. The compilation step is necessary before it is possible to execute queries. Compilation reorganizes the schema, computes various defaults, performs verifications, and does a number of other necessary steps.

The inject-resolvers function updates the schema, adding :resolve keys to fields. The keys of the map identify a type and a field, and the value is the resolver function.

The field resolver in this case is just a temporary placeholder; it ignores all the arguments passed to it, and simply returns nil. Like all field resolver functions, it accepts three arguments: a context map, a map of field arguments, and a container value. We’ll discuss what these are and how to use them shortly.

user namespace

A key advantage of Clojure is REPL-oriented [3] development: we want to be able to run our code through its paces almost as soon as we’ve written it - and when we change code, we want to be able to try out the changed code instantly.

Clojure, by design, is almost uniquely suited for this interactive style of development. Features of Clojure exist just to support REPL-oriented development, and its one of the ways in which using Clojure will vastly improve your productivity!

We can add a bit of scaffolding to the user namespace, specific to our needs in this project. When you launch a REPL, it always starts in this namespace.

The user.clj needs to be on the classpath, but shouldn’t be packaged when we eventually build a Jar from our project. We need to introduce a new alias in the deps.edn for this.

An alias is used to extend the base dependencies with more information about running the project; this includes extra source paths, extra dependencies, and extra configuration about what function to run at startup.

We’re going to start by adding a :dev alias:

deps.edn
{:paths ["src" "resources"]
 :deps  {org.clojure/clojure     {:mvn/version "1.11.1"}
         com.walmartlabs/lacinia {:mvn/version "1.2-alpha-4"}}
 :aliases
 {:run-m {:main-opts ["-m" "my.clojure-game-geek"]}
  :run-x {:ns-default my.clojure-game-geek
          :exec-fn    greet
          :exec-args  {:name "Clojure"}}
  :build {:deps       {io.github.seancorfield/build-clj
                       {:git/tag   "v0.8.2" :git/sha "0ffdb4c"
                        ;; since we're building an app uberjar, we do not
                        ;; need deps-deploy for clojars.org deployment:
                        :deps/root "slim"}}
          :ns-default build}
  :dev   {:extra-paths ["dev-resources"]}
  :test  {:extra-paths ["test"]
          :extra-deps  {org.clojure/test.check {:mvn/version "1.1.1"}
                        io.github.cognitect-labs/test-runner
                        {:git/tag "v0.5.0" :git/sha "48c3c67"}}}}}

We can now define the user namespace in the dev-resources folder; this ensures that it is not included with the rest of our application when we eventually package and deploy the application.

dev-resources/user.clj
(ns user
  (:require
    [my.clojure-game-geek.schema :as s]
    [com.walmartlabs.lacinia :as lacinia]))

(def schema (s/load-schema))

(defn q
  [query-string]
  (lacinia/execute schema query-string nil nil))

The key function is q (for query), which invokes com.walmartlabs.lacinia/execute.

We’ll use that to test GraphQL queries against our schema and see the results directly in the REPL: no web browser necessary!

With all that in place, we can launch a REPL and try it out:

> clj -M:dev
Clojure 1.11.1
user=> (q "{ gameById(id: \"foo\") { id name summary }}")
{:data #ordered/map ([:gameById nil])}
user=>

The clj -M:dev indicates that a REPL should be started that includes the :dev alias; this is what adds dev-resources to the classpath and the user namespace is then loaded from dev-resources/user.clj.

We get an odd result when executing the query; not a map but that strange #ordered/map business.

This is because value for the :data key makes use of an ordered map - a map that always orders its keys in the exact order that they are added to the map. That’s part of the GraphQL specification: the order in which fields appear in the query dictates the order in which they appear in the result. Clojure’s map implementations don’t always keep keys in the order they are added.

In any case, this result is equivalent to {:data {:gameById nil}}.

That’s as it should be: the resolver was unable to resolve the provided id to a BoardGame, so it returned nil. This is not an error … remember that we defined the type of the gameById query to allow nulls, just for this specific situation.

However, Lacinia still returns a map with the operation name and the selection for that operation.

Summary

We’ve defined an exceptionally simple schema in EDN, but still have managed to load it into memory and compile it. We’ve also used the REPL to execute a query against the schema and seen the initial (and quite minimal) result.

In the next chapter, we’ll build on this modest start, introducing more schema types, and a few helpers to keep our code clean and easily testable.

[1]Internally, everything is converted to keywords, so if you prefer to use symbols everywhere, nothing will break. Conversion to keywords is one part of the schema compilation process.
[2]Because the input schema format is so complex, it is always validated using clojure.spec. This helps to ensure that minor typos or other gaffes are caught early rather than causing you great confusion later.
[3]Read Eval Print Loop: you type in an expression, and Clojure evaluates and prints the result. This is an innovation that came early to Lisps, and is integral to other languages such as Python, Ruby, and modern JavaScript. Stuart Halloway has a talk, Running with Scissors: Live Coding With Data, that goes into a lot more detail on how important and useful the REPL is.