AbsintheProjector
Ecto preload trees derived from Absinthe selection sets.
AbsintheProjector is an Absinthe middleware that converts each query's selection set into an exact Ecto preload tree — discovered from your Ecto schemas by reflection, never a hand-maintained list — ready to drop into a single Repo.preload/2.
{
order {
number
customer { name }
items { product { supplier { name } } }
}
}
becomes, automatically:
[:customer, items: [product: [:supplier]]]
No association N+1. No association overfetch. No rewriting resolvers. No spreading dataloader across your schema.
Why
Every Absinthe + Ecto API picks one of two defaults:
- Fixed preloads in the resolver — you preload everything any client might ask for, on every request. Most of it is wasted queries.
- Dataloader — solves N+1 by batching, but asks you to restructure your schema with per-field resolvers, which fights the common "domain service returns a fully-loaded struct" architecture.
There is a third option hiding in plain sight: Absinthe already knows exactly what the client selected (Absinthe.Resolution.project/1), and Ecto already knows which fields are associations (__schema__(:associations)). AbsintheProjector just introduces them to each other.
| Fixed preloads | Dataloader | AbsintheProjector | |
|---|---|---|---|
| Loads only what's asked | ❌ | ✅ | ✅ |
| Keeps thin resolvers | ✅ | ❌ per-field | ✅ one middleware |
| Works with domain services returning loaded structs | ✅ | ❌ | ✅ |
| Config per association | none | source per type | none (reflection) |
Dataloader is still the right tool for batching across many parent records resolved independently. AbsintheProjector shines when your resolver loads a record (or a page of records) at the top and wants its associations preloaded in one shot — the dominant shape in service-layer architectures.
Installation
def deps do
[
{:absinthe_projector, "~> 0.1.0"}
]
end
Requires Elixir ~> 1.14, absinthe ~> 1.7, ecto ~> 3.10.
Quick start
Declare the middleware on a field, naming the root Ecto schema of that field's return type:
query do
field :order, :order do
arg(:id, non_null(:id))
middleware(AbsintheProjector, schema: MyApp.Order)
resolve(&Resolvers.get_order/2)
end
end
Read the computed preload tree in the resolver and pass it down as plain data:
def get_order(%{id: id}, resolution) do
preloads = AbsintheProjector.preloads(resolution)
Orders.get(id, preloads)
end
Your domain code stays Absinthe-free — the tree is an ordinary keyword list that ends up in Repo.preload/2 (or a preload(^tree) query composition):
def get(id, preloads \\ []) do
case Repo.get(Order, id) do
nil -> {:error, :not_found}
order -> {:ok, Repo.preload(order, preloads)}
end
end
That's the whole integration. Scalar fields, __typename, aliases, fragments and duplicate selections are all handled; anything that isn't a real association of the schema is ignored.
Pagination envelopes
List fields that wrap entities in a pagination envelope (Flop's data, or a custom page { entries }) declare where the entities live with :envelope:
field :orders, :order_page do
middleware(AbsintheProjector, schema: MyApp.Order, envelope: :data)
resolve(&Resolvers.list_orders/2)
end
:envelope takes an atom (:data) or a list descended in order ([:page, :entries]). Sibling fields outside the envelope (meta, totalCount, …) are never projected, and a query that skips the envelope entirely yields [] — always a safe no-op for Repo.preload/2.
How it works
Absinthe.Resolution.project/1gives the middleware the client's selection set, already normalized — fragments expanded,@skip/@includeapplied, each field carrying its schema identifier.- The optional envelope path is descended structurally.
- The engine walks the selection recursively, asking Ecto's reflection API at every level: is this field a real association, and which schema do I recurse into? — including
has_throughchains. Adding an association to a schema makes it projectable automatically; there is no whitelist to maintain, so there is no whitelist to drift. - The resulting tree is stored on
resolution.context;AbsintheProjector.preloads/1reads it back ([]when the middleware didn't run, so shared resolver helpers can call it unconditionally).
Everything is pure: no process state, no database access, no configuration.
Fail-loud by design
Static misconfiguration surfaces immediately instead of degrading into an empty preload:
- missing
:schema→ArgumentErrorwith usage guidance; :schemathat isn't an Ecto schema module →ArgumentErrornaming the module;- malformed
:envelope→ArgumentErrornaming the value.
A resolution that already carries errors passes through untouched — upstream failures are never masked.
Troubleshooting
Installed the middleware and preloads come back empty? Force a recompile:
mix compile --force
Absinthe compiles your schema's imported type modules into a cached blueprint; swapping middleware inside an import_types module doesn't always invalidate it. This is an Absinthe compilation quirk, not specific to this library — one --force after wiring the middleware and you're done.
License
MIT — see LICENSE.