You Probably Don't Need GraphQL (Yet)
Most Phoenix apps don't need GraphQL. Here's how to know when PostgreSQL and Ecto are enough, and when Absinthe actually earns its complexity.
TL;DR
- PostgreSQL + Ecto handles most Phoenix apps. If you’re building a LiveView app with one web frontend, adding GraphQL is adding complexity for no benefit.
- LiveView eliminates the main reason for an API layer. No JSON serialization, no API versioning, no client-server contract to maintain. Your LiveView calls your contexts directly.
- GraphQL earns its place with multiple clients. When a web app, mobile app, and third-party integrations all need different slices of the same data, that’s when Absinthe starts paying for itself.
- The industry is course-correcting. After years of “use GraphQL for everything,” teams are realizing it was the right tool for about 10% of the projects that adopted it.
- Start simple, add layers when the pain is real. Ecto contexts are your internal API. REST is your first external API. GraphQL is your answer to client diversity at scale.
I use Absinthe in production. I also have projects where the entire data layer is Ecto talking to PostgreSQL with no API layer at all. The difference between those projects isn’t sophistication. It’s whether the complexity is justified.
Most of the time, it isn’t. Here’s how I think about it.
The Default: PostgreSQL + Ecto + Contexts
For the majority of Phoenix applications, the right architecture is the simplest one. Your LiveView (or controller) calls a context function. The context function calls Ecto. Ecto talks to PostgreSQL. Done.
# This is your "API layer" for most Phoenix apps
def mount(_params, _session, socket) do
projects = Projects.list_active_for_user(socket.assigns.current_user)
{:ok, assign(socket, projects: projects)}
end
There’s no serialization. No schema definition beyond your Ecto schemas. No resolver functions. No query complexity analysis. The LiveView process calls an Elixir function in the same application. It’s fast, it’s simple, and there’s nothing to go wrong between the request and the data.
Phoenix’s context pattern gives you the same separation of concerns that an API layer would. Controllers and LiveViews don’t know about database tables. Contexts don’t know about HTTP or WebSockets. You get clean boundaries without the overhead of a formal API contract.
This is the right choice when:
- You have one web frontend (LiveView or traditional server-rendered)
- Your team controls both the frontend and backend
- You don’t have external API consumers
- Real-time updates come through LiveView and PubSub (which they usually should)
That covers a lot of applications. More than most people think.
Why LiveView Changes Everything
Before LiveView, almost every interactive web app needed some kind of API. Your JavaScript frontend had to talk to your backend over HTTP. Whether you chose REST or GraphQL, you needed a serialization layer.
LiveView removed that requirement entirely. The server renders HTML and pushes diffs over a WebSocket. Form validation happens through Ecto changesets rendered in real-time. Interactive UI elements are Elixir functions, not JavaScript API calls.
The payloads are smaller too. LiveView sends compressed HTML diffs. For most interactions, that’s less data than even an optimized GraphQL response would be.
If you’re building a Phoenix app with LiveView and your only client is a web browser, you don’t need REST. You don’t need GraphQL. You need Ecto and good context boundaries. Everything else is overhead.
When REST Makes Sense
The moment you need to serve a client that isn’t your LiveView frontend, you need an API. A mobile app. A third-party integration. A webhook consumer. A separate SPA.
For most of these cases, REST is the right first step. Phoenix controllers already give you a clean way to build JSON APIs. The patterns are familiar. Caching works out of the box with standard HTTP headers. Your team probably already knows how to build and consume REST endpoints.
REST works well when:
- You have one or two API consumers with predictable data needs
- Your resources map cleanly to CRUD operations
- You want standard HTTP caching
- Your team is small and controls all the clients
You can run LiveView for your web UI and REST for your mobile app side by side. The business logic lives in your contexts either way. Adding a JSON controller doesn’t mean rearchitecting anything.
When GraphQL Actually Earns Its Keep
GraphQL solves a specific problem: client diversity. When multiple clients with different data needs all consume the same backend, and those clients are evolving independently, GraphQL’s flexibility becomes genuinely valuable.
Here’s what that looks like in practice:
- Your web dashboard needs users with their projects, tasks, and recent activity
- Your mobile app needs users with just their name and active task count
- Your partner integration needs users with billing info and usage metrics
- All three are maintained by different teams shipping on different schedules
With REST, you end up with one of two bad options. Either you build separate endpoints for each client (tripling your API surface), or you build generic endpoints that over-fetch for mobile and under-fetch for the dashboard.
With Absinthe, each client writes the query it needs:
# Mobile app - minimal payload
query {
currentUser {
name
activeTaskCount
}
}
# Web dashboard - rich nested data
query {
currentUser {
name
email
projects {
name
tasks(status: ACTIVE) {
title
assignee { name }
}
}
recentActivity(limit: 10) {
action
timestamp
}
}
}
Same endpoint. Same backend logic. Different payloads. That’s the promise of GraphQL, and it’s real. But only when you actually have this problem.
The Complexity You’re Signing Up For
GraphQL isn’t free. When you add Absinthe to your Phoenix app, here’s what comes with it:
Schema duplication. You already have Ecto schemas defining your data. Now you’re defining Absinthe types that mirror them. Every field, every association, defined twice. They drift apart over time if you’re not careful.
N+1 queries by default. GraphQL resolvers execute per-field. Without Dataloader (Absinthe’s batching library), a query that fetches 50 users with their projects generates 51 database queries. You have to think about this for every resolver that touches associations.
Security surface area. A single GraphQL endpoint accepts arbitrary queries. A malicious (or just careless) client can send a deeply nested query that generates thousands of database calls. You need query depth limits, complexity analysis, and rate limiting. None of this is a concern with REST.
Caching gets hard. GraphQL uses POST requests to a single endpoint. Standard HTTP caching doesn’t work. You need application-level caching strategies, which adds more code and more things to get wrong.
Observability suffers. Every request returns HTTP 200, even failures. Errors are embedded in the response body. Your standard monitoring tools won’t catch them without custom instrumentation.
These aren’t dealbreakers. They’re tradeoffs. But they’re tradeoffs you should only make when the benefits clearly outweigh the costs.
Absinthe-Specific Advice
If you do reach the point where GraphQL is justified, Absinthe is excellent. It fits naturally into the Elixir ecosystem and the functional style maps well to the resolver model. A few things I’ve learned:
Use Dataloader from day one. Don’t write resolvers that call Repo.preload. Set up Dataloader sources per context and use the dataloader() helper in your field definitions. This is non-negotiable for any Absinthe app that touches associations.
Mirror your context boundaries. Organize your Absinthe types and resolvers the same way you organize your contexts. If you have a Projects context and an Accounts context, have corresponding type modules. This keeps your GraphQL schema from becoming a monolith even when your app is one.
Subscriptions are great (when you need them). Absinthe subscriptions run on Phoenix Channels, which means they’re built on mature, battle-tested infrastructure. But if your only client is a web browser, LiveView’s built-in real-time updates are simpler for the same result.
My Decision Framework
Here’s the mental model I use for every project:
Start with Ecto + contexts. This is your foundation regardless of what comes later. Well-designed contexts are your internal API. If you never need an external one, you’ve saved yourself a ton of complexity.
Add REST when you need an external API. First mobile app? Third-party webhook? Partner integration? Add a Phoenix controller that returns JSON. You can build this in an afternoon and your contexts already have the business logic.
Add GraphQL when client diversity becomes painful. Three or more clients with meaningfully different data needs. Teams shipping independently. An API surface that’s buckling under one-off endpoints. That’s when Absinthe earns its place.
Never add GraphQL “just in case.” The migration path from REST to GraphQL is well-understood. You can run both side by side. The cost of adding it later is much lower than the cost of maintaining it when you don’t need it.
The Industry Is Figuring This Out
There’s been a wave of “GraphQL was a mistake” content over the past year or so. Posts with titles like “GraphQL Was a Mistake for 90% of the Teams Using It” and “The Enterprise Honeymoon Is Over” are getting serious traction.
They’re not wrong, but they’re not telling the whole story either. GraphQL wasn’t a mistake. Premature adoption was. Facebook built GraphQL because they had hundreds of teams building different clients against the same data. Most of us don’t have that problem.
The technology works. It solves a real problem. That problem just happens to be more niche than the hype cycle suggested.
Use the Smallest Tool That Works
I build Elixir applications for a living. Some of them use Absinthe. Most of them don’t. The ones that don’t aren’t less sophisticated. They’re appropriately scoped.
PostgreSQL is remarkable. Ecto is one of the best database libraries in any language. Phoenix contexts give you clean architecture without ceremony. LiveView eliminates the API layer for web applications entirely.
Start there. Add complexity when you feel the pain, not before. Your future self (and your clients) will thank you for the code you didn’t write.
David Kerr is the founder of Kerrberry Systems. He builds custom software in Elixir and Phoenix, and only adds GraphQL when the project actually needs it. Find him on LinkedIn or GitHub.