A fight I keep getting into at work: my VP of Engineering and my EM think Ecto is an ORM. With all the traditional ORM downsides.
They’re not wrong to be suspicious. ORMs have a well-earned reputation for hiding complexity, generating bad SQL, and falling apart at scale. But Ecto isn’t an ORM.
It gives you three things. Schemas that map to tables. Changesets for validation. And queries that are just data structures — %Ecto.Query{} structs you can inspect, compose, and manipulate like any other Elixir data.
Once you internalise that, a lot of doors open.
Queries Are Data
Here’s a basic Ecto query:
from u in User,
where: u.age > 18,
select: u.name
You can pipe it too:
User
|> where([u], u.age > 18)
|> select([u], u.name)
Both produce the same SQL. But here’s the part that matters: the query itself is just a struct. You can inspect it:
iex> IO.inspect(query, structs: false)
%{
__struct__: Ecto.Query,
from: %{ source: {"users", User} },
wheres: [%{ expr: ... }],
order_bys: [],
joins: [],
group_bys: [],
...
}
It’s not a string. It’s not a prepared statement. It’s a plain data structure with fields you can read, modify, and compose. This is the foundation everything else builds on.
The Pipe-Everything Pattern
The common pattern I see in the wild looks like this:
def count_users(org_id) do
User
|> User.where_not_deleted()
|> User.where_org_id(org_id)
|> Repo.aggregate(:count)
end
def list_users(org_id) do
User
|> User.where_not_deleted()
|> User.where_org_id(org_id)
|> Repo.all()
end
Each filter is its own function on the schema module. It works — you get query reuse across context functions. But it comes with problems:
- Every new filter needs a new function. The boilerplate adds up.
- The filters are rigid.
where_org_id/2only does one thing. - You’re not really composing — you’re stacking named functions that each know about one specific filter.
We can do better.
The Queryable Behaviour
Instead of one function per filter, what if each schema defined a single function that took a query and a list of filters and handed back a transformed query?
That’s a reducer: (query, filter) -> query. If filters are just data, we can walk them with Enum.reduce.
Start with a behaviour:
defmodule MyApp.Queryable do
@callback base_query() :: Ecto.Queryable.t()
@callback query(Ecto.Queryable.t(), keyword()) :: Ecto.Queryable.t()
end
Two callbacks. base_query/0 gives you the starting query — shared constraints like soft-delete scoping. query/2 takes that query and a keyword list of filters, hands back a modified query.
Any schema that implements this is queryable:
defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Query
@behaviour MyApp.Queryable
schema "users" do
field :name, :string
field :deleted_at, :utc_datetime
belongs_to :org, Org
end
@impl MyApp.Queryable
def base_query do
from u in User, as: :self, where: is_nil(u.deleted_at)
end
@impl MyApp.Queryable
def query(base_query, filters) do
Enum.reduce(filters, base_query, fn
{:org_id, id}, query -> from(u in query, where: u.org_id == ^id)
{:name, name}, query -> from(u in query, where: u.name == ^name)
{field, value}, query -> apply_filter(query, field, value)
end)
end
end
Custom filters get matched first. Anything unrecognised falls through to apply_filter/3 — a shared function that knows how to handle equality, nil, {:gt, value}, {:in, values}, %Regex{} for LIKE, and more.
Context functions collapse to a single call:
def count_users(org_id) do
Repo.aggregate(User.query(org_id: org_id), :count)
end
def list_users(org_id) do
Repo.all(User.query(org_id: org_id))
end
No per-filter functions. No boilerplate. Adding a filter means one clause in query/2 — or nothing at all if apply_filter already handles it.
We open-sourced this pattern as Patterns.Queryable, part of the Patterns library. The core idea — queries as data, filters as reducers — is what matters. Patterns just codifies the parts everyone was rewriting anyway.
Making It the Default
Once we started using this everywhere, two things became obvious. Most schemas never needed custom filters — apply_filter handled everything. And when they did, the Enum.reduce + fallthrough shape was always the same.
So we wrote a macro that injects the default:
defmodule MyApp.Queryable do
defmacro __using__(_opts) do
quote do
use Ecto.Schema
import Ecto.Query
import unquote(__MODULE__)
@behaviour unquote(__MODULE__)
@impl unquote(__MODULE__)
def base_query do
base_query = from x in __MODULE__, as: :self
if :deleted_at in __schema__(:fields) do
from x in base_query, where: is_nil(x.deleted_at)
else
base_query
end
end
@impl unquote(__MODULE__)
def query(base_query, filters) do
Enum.reduce(filters, base_query, &apply_filter(&2, &1))
end
defoverridable base_query: 0, query: 2
end
end
end
Now a schema that just needs basic filtering is one line:
defmodule MyApp.Accounts.User do
use MyApp.Queryable
schema "users" do
field :name, :string
field :deleted_at, :utc_datetime
belongs_to :org, Org
end
end
It gets base_query/0 with soft-delete scoping, query/2 that delegates to apply_filter, and can override either for custom logic.
The named binding (as: :self) means association filters compose naturally. When you pass org: [name: "Acme"], apply_filter joins org, switches the binding context, and delegates the nested filter to Org.query/2.
The Layers That Matter
An architecture rule that’s served us well: query logic lives in the schema, business logic lives in the context, and the API layer just translates.
Your context function doesn’t need to know about GraphQL arguments or REST params. It takes a keyword list of domain filters, passes them to Schema.query/2, and hands the result back. The resolver’s job is to map %{first: 10, after: "cursor"} into [first: 10, after: "cursor"] and call the context. That’s it.
This means business logic can list things however it wants. It might return lists, cursors, whatever. The API layer just calls the function and minimally wraps the result. Pagination, filtering, ordering — those belong to the domain, not the transport.
Pagination as Metadata
We handle Relay-style cursor pagination the same way: as query metadata, not as a separate concern. When you pass first: 10 or after: "cursor" as part of the keyword filters, the Queryable behaviour detects them, extracts them from the filter list, and stores them as metadata on the query struct.
At execution time, a thin middleware layer in the repo intercepts Repo.all/2, checks for pagination metadata, and delegates to the appropriate pagination strategy. The schema never writes pagination logic. The context never writes pagination logic. It all lives in one place — the Queryable behaviour itself.
The Payoff
Queries are data. Filters are reducers. Once you build on that foundation, the rest follows: composable queries, zero-boilerplate schemas, Dataloader integration for free, pagination that lives in the domain layer, and a single source of truth for how your data gets fetched.
My VP and EM haven’t come around yet. Maybe they never will. But if you’ve ever fought an ORM that thought it knew better than you, this pattern is the antidote.
Treat queries like data. Everything else is just Enum.reduce.
▼
▶
Directory
1
- 01.
Ecto Queryable Pattern
Ecto queries are just data structures, treat them that way and profit.