Clojure + GraphQL Integration

Table of Contents

Overview

Lacinia is the de facto GraphQL server for Clojure, written by Walmart Labs and released as open source in 2017. It tracks the GraphQL specification and represents schemas as EDN data with resolvers as ordinary Clojure functions. The library is idiomatic for Clojure shops building API layers because the schema, the request, and the response are all just data the program can transform.

// Clojure GraphQL stack — EDN schema → Lacinia parser → resolver tree → context map → response
digraph clojure_graphql_stack {
    rankdir=LR;
    graph [bgcolor="white", fontname="Helvetica", fontsize=11,
           pad="0.3", nodesep="0.3", ranksep="0.45"];
    node  [shape=box, style="rounded,filled", fontname="Helvetica",
           fontsize=10, fillcolor="#f5f5f5", color="#888"];
    edge  [color="#aaa"];
    // Tailwind palette: #d36 #d63 #693 #369 #639 #963 — node fill #f5f5f5, border #888, edges #aaa

    // Schema-as-data (left)
    subgraph cluster_schema {
        label="Schema as data (EDN)"; labeljust="l";
        color="#369"; fontcolor="#369"; style="rounded";
        edn      [label="schema.edn\n{:objects ... :queries ...}", color="#369"];
        resolvers [label="resolver map\n{:resolve-user #'resolve-user}", color="#369"];
        compile  [label="schema/compile\n+ util/inject-resolvers", color="#369"];
        edn       -> compile;
        resolvers -> compile;
    }

    // Lacinia core
    parser   [label="Lacinia parser\nparse · validate · plan", color="#693"];
    rtree    [label="resolver tree\n(per request)",            color="#693"];
    context  [label="context map\n{:request ... :db ... :loader ...}", color="#963"];
    response [label="response map\n{:data ... :errors ...}",    color="#d63"];

    compile -> parser;
    parser  -> rtree;
    context -> rtree [style=dashed];
    rtree   -> response;

    // Side cluster — Clojure GraphQL ecosystem
    subgraph cluster_eco {
        label="Ecosystem (alternatives + tooling)"; labeljust="l";
        color="#639"; fontcolor="#639"; style="rounded";
        builder  [label="graphql-builder\n(client query strings\nfrom .graphql files)", color="#639"];
        alumbra  [label="alumbra\n(server, last release 2017)", color="#639"];
        gqlclj   [label="graphql-clj\n(server, archived 2019)",  color="#639"];
    }

    builder -> parser [style=dotted, label="queries", fontsize=9, fontcolor="#639"];
    alumbra -> response [style=dotted, label="alt path", fontsize=9, fontcolor="#639"];
    gqlclj  -> response [style=dotted, label="alt path", fontsize=9, fontcolor="#639"];
}

diagram-clojure-graphql-stack.png

Background

GraphQL emerged from Facebook in 2015 as an alternative to REST APIs, offering clients the ability to request exactly the data they need. For Clojure applications, Lacinia (named after a species of lacewing) became the de facto standard, first released in 2017. The library integrates naturally with Ring-based web servers and supports both synchronous and asynchronous execution models through core.async and manifold.

Key Concepts

Schema Definition

Lacinia schemas are defined as EDN maps describing types, queries, mutations, and subscriptions:

{:objects
 {:User
  {:fields {:id {:type ID}
            :name {:type String}
            :email {:type String}}}}
 :queries
 {:user {:type :User
         :args {:id {:type ID}}
         :resolve :resolve-user}}}

Resolvers

Field resolvers are functions that receive context, arguments, and the parent value:

(defn resolve-user [context args value]
  (let [{:keys [id]} args]
    (db/find-user-by-id id)))

Async Execution

Lacinia supports asynchronous resolvers using resolve-promise for non-blocking database queries and external API calls.

Pedestal Integration

lacinia-pedestal provides HTTP transport with features like GraphiQL playground, subscription support via WebSockets, and request tracing.

Implementation

Project Setup

;; deps.edn
{:deps {com.walmartlabs/lacinia {:mvn/version "1.2"}
        com.walmartlabs/lacinia-pedestal {:mvn/version "1.2"}}}

Schema Compilation

(require '[com.walmartlabs.lacinia.schema :as schema])
(require '[com.walmartlabs.lacinia.util :as util])

(def compiled-schema
  (-> schema-edn
      (util/inject-resolvers {:resolve-user resolve-user})
      schema/compile))

Query Execution

(require '[com.walmartlabs.lacinia :as lacinia])

(lacinia/execute compiled-schema
                 "{ user(id: \"123\") { name email } }"
                 nil nil)
// Resolver execution flow — HTTP request → Ring → Lacinia parse/validate/plan → resolver tree → response
digraph resolver_flow {
    rankdir=LR;
    graph [bgcolor="white", fontname="Helvetica", fontsize=11,
           pad="0.3", nodesep="0.3", ranksep="0.45"];
    node  [shape=box, style="rounded,filled", fontname="Helvetica",
           fontsize=10, fillcolor="#f5f5f5", color="#888"];
    edge  [color="#aaa"];

    // HTTP ingress
    subgraph cluster_http_in {
        label="HTTP ingress"; labeljust="l";
        color="#369"; fontcolor="#369"; style="rounded";
        http_req  [label="HTTP POST /graphql\n{query, variables}", color="#369"];
        ring      [label="Ring handler\n(lacinia-pedestal)", color="#369"];
        http_req -> ring;
    }

    // Lacinia pipeline
    subgraph cluster_pipeline {
        label="Lacinia pipeline"; labeljust="l";
        color="#693"; fontcolor="#693"; style="rounded";
        parse     [label="parse\n(antlr4 grammar)", color="#693"];
        validate  [label="validate\nagainst schema", color="#693"];
        plan      [label="plan\n(execution strategy)", color="#693"];
        parse -> validate -> plan;
    }

    // Context threading
    subgraph cluster_context {
        label="Context map (per request)"; labeljust="l";
        color="#963"; fontcolor="#963"; style="rounded";
        ctx [label="{:request r\n :db conn\n :loader batch-fn\n :auth user}", color="#963"];
    }

    // Resolver tree
    subgraph cluster_resolvers {
        label="Resolver tree (fan-out)"; labeljust="l";
        color="#d36"; fontcolor="#d36"; style="rounded";
        root_res  [label="root resolver\n:resolve-query", color="#d36"];
        child1    [label="field resolver\n:resolve-user", color="#d36"];
        child2    [label="field resolver\n:resolve-posts", color="#d36"];
        child3    [label="field resolver\n:resolve-comments", color="#d36"];
        root_res -> child1;
        root_res -> child2;
        child2   -> child3;
    }

    // Response assembly
    subgraph cluster_response {
        label="Response assembly"; labeljust="l";
        color="#d63"; fontcolor="#d63"; style="rounded";
        assemble  [label="merge resolver results\n{:data {...} :errors [...]}", color="#d63"];
        http_resp [label="HTTP 200\nContent-Type: application/json", color="#d63"];
        assemble -> http_resp;
    }

    // Annotation node
    note_ctx [label="context threaded\ninto every resolver\nas first arg", shape=note,
              style=filled, fillcolor="#fffde7", color="#aaa", fontsize=9];

    ring  -> parse;
    plan  -> root_res;
    ctx   -> root_res [style=dashed];
    note_ctx -> ctx   [style=dotted, color="#ccc"];
    child1   -> assemble;
    child2   -> assemble;
    child3   -> assemble;
}

diagram-clojure-graphql-resolver-flow.png

References

Notes

  • Lacinia enforces schema-first design, encouraging API contracts before implementation
  • The library handles GraphQL introspection queries automatically
  • Consider using lacinia-gen for property-based testing of resolvers
  • For production, implement DataLoader patterns to avoid N+1 query problems
// DataLoader / N+1 — naive vs batched
digraph dataloader {
    rankdir=TB;
    graph [bgcolor="white", fontname="Helvetica", fontsize=11,
           pad="0.3", nodesep="0.4", ranksep="0.5"];
    node  [shape=box, style="rounded,filled", fontname="Helvetica",
           fontsize=10, fillcolor="#f5f5f5", color="#888"];
    edge  [color="#aaa"];

    // Naive N+1 path
    subgraph cluster_naive {
        label="Naive (N+1 queries)"; labeljust="l";
        color="#d36"; fontcolor="#d36"; style="rounded";
        n_query   [label="query { posts { author { name } } }", color="#d36"];
        n_posts   [label="SELECT * FROM posts\n→ [p1, p2, p3, ...]", color="#d36"];
        n_q1      [label="SELECT * FROM users\nWHERE id = p1.author_id", color="#d36"];
        n_q2      [label="SELECT * FROM users\nWHERE id = p2.author_id", color="#d36"];
        n_qn      [label="SELECT * FROM users\nWHERE id = pN.author_id\n(one per post)", color="#d36"];
        n_query -> n_posts;
        n_posts -> n_q1;
        n_posts -> n_q2;
        n_posts -> n_qn;
    }

    // Batched DataLoader path
    subgraph cluster_batched {
        label="DataLoader (1+1 queries)"; labeljust="l";
        color="#693"; fontcolor="#693"; style="rounded";
        b_query   [label="query { posts { author { name } } }", color="#693"];
        b_posts   [label="SELECT * FROM posts\n→ [p1, p2, p3, ...]", color="#693"];
        b_collect [label="DataLoader\ncollect author-ids\n[id1, id2, id3, ...]", color="#693"];
        b_batch   [label="SELECT * FROM users\nWHERE id IN (id1, id2, id3, ...)\n(single query)", color="#693"];
        b_cache   [label="result cache\ndeliver to each resolver", color="#693"];
        b_query -> b_posts;
        b_posts -> b_collect [label="resolver-promise\nper post", fontsize=9, fontcolor="#693"];
        b_collect -> b_batch;
        b_batch -> b_cache;
    }

    // Annotation nodes
    note_bad  [label="N queries for N posts\nlatency scales linearly", shape=note,
               style=filled, fillcolor="#fdecea", color="#aaa", fontsize=9];
    note_good [label="Keys batched per tick\nvia resolve-promise\n+ inject-callbacks", shape=note,
               style=filled, fillcolor="#e8f5e9", color="#aaa", fontsize=9];

    note_bad  -> n_qn   [style=dotted, color="#ccc"];
    note_good -> b_cache [style=dotted, color="#ccc"];
}

diagram-clojure-graphql-dataloader.png

DataLoader Batching Example

The standard pattern threads a batch function through the context map, collects keys across sibling resolvers during a single execution tick, then dispatches one database call.

(ns examples.dataloader
  (:require [com.walmartlabs.lacinia.resolve :as resolve]
            [com.walmartlabs.lacinia.executor :as executor]))

;; Batch-load function — receives a collection of author-ids,
;; returns a map of id → user record.
(defn batch-load-users
  [db author-ids]
  (let [rows (db/query db ["SELECT * FROM users WHERE id = ANY(?)"
                            (into-array author-ids)])]
    (into {} (map (juxt :id identity) rows))))

;; Resolver that returns a promise and registers its key with the loader.
(defn resolve-post-author
  [{:keys [db loader]} _args post]
  (let [author-id (:author-id post)
        p         (resolve/resolve-promise)]
    ;; inject-callbacks collects [author-id → promise] pairs across all
    ;; sibling resolvers executing in the same tick; on tick completion
    ;; Lacinia calls the batch-fn once with all collected ids.
    (executor/inject-callbacks
      loader
      author-id
      (fn [user-map]
        (resolve/deliver! p (get user-map author-id) nil)))
    p))

;; Wire the loader into the context at handler startup.
(defn make-context [db]
  (let [loader (executor/batch-loader (partial batch-load-users db))]
    {:db db :loader loader}))

Related notes

  • GraphQL — request lifecycle, federation, persisted queries; Lacinia is the Clojure point in that broader landscape.
  • clojure.spec — natural fit for validating resolver inputs and conforming the EDN schema map before schema/compile.
  • Datalayer schema — schema-as-data with EDN/Datomic; same data-first stance Lacinia takes for the GraphQL surface.
  • libpython-clj2 — when a resolver needs a Python ML model, libpython-clj2 is the in-process bridge instead of an extra service hop.
  • Scheme — code-as-data lineage; the EDN-schema/EDN-resolver pattern is the same homoiconic move Scheme has used for decades.

Postscript (2026)

Lacinia 1.2 (February 2023, with 1.2.1 in April 2023) is still the current release line as of April 2026 — no 2.0, and the walmartlabs/lacinia commit cadence is maintenance-only. The Clojure GraphQL stack has effectively stagnated against the JavaScript side, where Apollo Federation v2 became the default for multi-team graphs and GraphQL Yoga absorbed most new server-side mindshare. Hasura, which several Clojure shops adopted from 2020-2022 to skip writing resolvers entirely, has been rolled back in places after Hasura DDN (beta April 2024, GA August 2024) reframed the product around a paid control plane. Persisted queries are now standardised as Trusted Documents — clients send a hash, the server executes only registered operations — and Lacinia users implement this manually since there is no built-in registry. The practical 2026 question for a new Clojure service is whether to stay on Lacinia, put Apollo Router in front of multiple subgraphs (one of which happens to be Lacinia), or skip GraphQL and ship typed RPC; the answer increasingly depends on whether the front end is already inside an Apollo Federation graph.

// 2026 architecture decision — Lacinia standalone vs Apollo Federation vs typed RPC
digraph options_2026 {
    rankdir=TB;
    graph [bgcolor="white", fontname="Helvetica", fontsize=11,
           pad="0.3", nodesep="0.5", ranksep="0.55"];
    node  [shape=box, style="rounded,filled", fontname="Helvetica",
           fontsize=10, fillcolor="#f5f5f5", color="#888"];
    edge  [color="#aaa"];

    client [label="Front-end client\n(React / mobile)", color="#888"];

    // Option A — Lacinia standalone
    subgraph cluster_option_a {
        label="Option A: Lacinia standalone"; labeljust="l";
        color="#369"; fontcolor="#369"; style="rounded";
        a_pedestal [label="Ring / Pedestal\n+ lacinia-pedestal", color="#369"];
        a_lacinia  [label="Lacinia 1.2\nschema.edn + resolvers", color="#369"];
        a_trusted  [label="Trusted Documents\n(manual registry)", color="#369"];
        a_db       [label="Datomic / PostgreSQL\n+ DataLoader batch", color="#369"];
        a_pedestal -> a_lacinia -> a_trusted -> a_db;
    }

    // Option B — Apollo Federation
    subgraph cluster_option_b {
        label="Option B: Apollo Router + Lacinia subgraph"; labeljust="l";
        color="#d36"; fontcolor="#d36"; style="rounded";
        b_router   [label="Apollo Router\n(federation v2 supergraph)", color="#d36"];
        b_lacinia  [label="Lacinia subgraph\n(@key directives, _entities)", color="#d36"];
        b_other    [label="JS / Python subgraphs\n(other teams)", color="#d36"];
        b_db       [label="Datomic / PostgreSQL", color="#d36"];
        b_router -> b_lacinia;
        b_router -> b_other;
        b_lacinia -> b_db;
    }

    // Option C — typed RPC (no GraphQL)
    subgraph cluster_option_c {
        label="Option C: Typed RPC (skip GraphQL)"; labeljust="l";
        color="#963"; fontcolor="#963"; style="rounded";
        c_rest     [label="Malli-validated REST\n(reitit + malli)", color="#963"];
        c_transit  [label="Transit-JSON API\n(clojure-flavoured EDN over HTTP)", color="#963"];
        c_grpc     [label="gRPC + protobuf\n(via protojure)", color="#963"];
        c_db       [label="Datomic / PostgreSQL", color="#963"];
        c_rest   -> c_db;
        c_transit -> c_db;
        c_grpc   -> c_db;
    }

    // Decision annotations
    note_a [label="Best when: greenfield Clojure shop,\nno existing federation graph,\nteam owns full stack", shape=note,
            style=filled, fillcolor="#e3f2fd", color="#aaa", fontsize=9];
    note_b [label="Best when: org already uses Apollo\nFederation; Clojure is one of\nseveral backend languages", shape=note,
            style=filled, fillcolor="#fdecea", color="#aaa", fontsize=9];
    note_c [label="Best when: client is internal only,\ntype safety > query flexibility,\nno GraphQL tooling investment", shape=note,
            style=filled, fillcolor="#e8f5e9", color="#aaa", fontsize=9];

    client -> a_pedestal [label="A", fontsize=9, fontcolor="#369"];
    client -> b_router   [label="B", fontsize=9, fontcolor="#d36"];
    client -> c_rest     [label="C", fontsize=9, fontcolor="#963"];
    client -> c_transit  [label="C", fontsize=9, fontcolor="#963"];
    client -> c_grpc     [label="C", fontsize=9, fontcolor="#963"];

    note_a -> a_lacinia  [style=dotted, color="#ccc"];
    note_b -> b_router   [style=dotted, color="#ccc"];
    note_c -> c_rest     [style=dotted, color="#ccc"];
}

diagram-clojure-graphql-2026-options.png

Author: Jason Walsh

j@wal.sh

Last Updated: 2026-04-20 21:34:32

build: 2026-04-20 23:45 | sha: d110973