Ash Framework vs writing a traditional backend
I've written a lot of backends — both the traditional way (separate controller + service + repository) and full-framework style. Lately I've been going deep on Ash Framework for Elixir, so I wanted to write an honest head-to-head comparison. No selling, no exaggeration — just the real pros and cons.
Ash isn't just an ORM or web framework — it's adeclarative backend framework. You define "resources" (like Post, User, Order) with actions, attributes, and policies, then extensions auto-generate GraphQL, REST APIs, authentication, authorization, and background jobs for you.
The same task, two ways
Say we're building a simple blog system — posts, authors, auth, a REST API, and a background job for publishing.
- Traditional way — write model, migration, controller, serializer, routes, auth middleware, policy checks, job worker separately. Each piece is 50–200 lines, easily thousands of lines for a single feature
- Ash way — declare one resource covering actions, attributes, policies, API endpoints, auth strategy, and oban triggers. The actual resource code is usually 50–150 lines
The numbers don't lie — for CRUD + API + auth + jobs that repeat,Ash saves real code at 5–10x because you're not writing the same plumbing over and over.
Real code example: a Blog Post
Let's look at actual code — say we need a Post resource with title, content, status (draft/published), and an author. It needs auth, owner-only permissions, and a REST API.
The Ash way — declare once, get everything
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "posts"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :content, :string
attribute :status, :atom,
constraints: [one_of: [:draft, :published]]
create_timestamp :inserted_at
update_timestamp :updated_at
end
relationships do
belongs_to :author, MyApp.Accounts.User
end
actions do
defaults [:read, :destroy]
create :publish do
accept [:title, :content]
argument :author_id, :uuid, allow_nil?: false
change set_attribute(:status, :published)
end
read :published do
filter expr(status == :published)
end
end
policies do
# Everyone can only read published posts
policy action_type(:read) do
authorize_if expr(status == :published)
end
# Only the owner can update/destroy
policy action_type([:update, :destroy]) do
authorize_if expr(author_id == ^actor(:id))
end
end
endThis single file gives you: migrations, validation, policy enforcement, and typed actions callable from other code. Want GraphQL? Just add extensions: [AshGraphql.Resource] and agraphql block — you get a full API without writing resolvers.
# Calling it — actions are plain typed functions
MyApp.Blog.Post.publish!(%{
title: "Hello Ash",
content: "...",
author_id: user.id
}, actor: user)
# And you get these GraphQL queries/mutations for free:
# post(id:), posts, createPost, updatePost, deletePostThe traditional way — write every piece yourself
// routes/posts.ts — write each endpoint separately
import { Router } from "express";
import { prisma } from "../db";
import { requireAuth } from "../middleware/auth";
const router = Router();
// GET /posts — write the query + filter + serialization yourself
router.get("/posts", async (req, res) => {
const posts = await prisma.post.findMany({
where: { status: "published" },
});
res.json(posts);
});
// POST /posts/publish — validate + auth + set status yourself
router.post("/posts/publish", requireAuth, async (req, res) => {
const { title, content } = req.body;
if (!title || !content) {
return res.status(400).json({ error: "missing fields" });
}
const post = await prisma.post.create({
data: {
title,
content,
status: "published",
authorId: req.user.id,
},
});
res.status(201).json(post);
});
// PATCH /posts/:id — check ownership every single time
router.patch("/posts/:id", requireAuth, async (req, res) => {
const post = await prisma.post.findUnique({
where: { id: req.params.id },
});
if (!post) return res.status(404).end();
if (post.authorId !== req.user.id) {
return res.status(403).end();
}
const updated = await prisma.post.update({
where: { id: req.params.id },
data: req.body,
});
res.json(updated);
});That's just 3 basic endpoints — not counting migrations, serializers, centralized error handling, tests, and the GraphQL schema you'd still need to write yourself. Every new endpoint repeats the same pattern, while in Ash you just declare a new action on the same resource.
Pros you'll actually feel
- Everything is introspectable — actions, attributes, policies are readable at runtime, so extensions auto-generate GraphQL/JSON:API/policies without you writing serializers twice
- Built-in authorization — write a policy once, it applies to every action and filters data automatically. Enumeration attack protection is built in — no remembering where to check permissions
- Elixir underneath — you get BEAM VM concurrency, fault tolerance, and pattern matching for free, no thread management needed
- Domain-first — the code reads like your business spec, newcomers understand it faster than controller spaghetti
- Complete ecosystem — AshAuthentication, AshOban, AshGraphql, AshJsonApi, AshCloak (encryption), AshAi (MCP tools) — plug in piece by piece as needed
Cons you have to accept
- Steep learning curve — you need to understand Elixir, OTP, and Ash's own paradigm. Early on you'll write slower than usual. If nobody on the team has used Elixir, expect 2–4 weeks to get comfortable
- Harder debugging — most code is macro/declarative, so errors sometimes have long, hard-to-read stack traces. You'll rely on docs and community more
- Smaller community than mainstream — weird problems may not have StackOverflow answers; you'll need the forum or Discord
- Vendor-ish ecosystem — many extensions are by the Ash core team; roadmap changes could affect your project
- Overkill for small projects — if it's just a landing page + contact form, don't use Ash. Next.js or Astro will finish faster
Cost-effectiveness: when it's worth it, when it's not
Worth it when your project has complex domain models — e-commerce, CRM, SaaS, multi-entity management systems. Ash excels at declaring once and getting many things. The more features, the clearer the difference.
Not worth it when the team has zero Elixir background and a tight deadline — the time spent learning the framework may exceed the benefit in the short term, or if the project is a prototype you'll throw away in two weeks.
Worth it long-term when the project has a long lifecycle — maintenance, feature additions, refactoring. Declarative code means changing a policy or adding a field automatically affects everything related, no need to hunt down every file.
Code efficiency: how to measure it
It's not just "writing less" — it's writing wrong less often. Because actions are typed, policies enforce at the data layer, and extensions generate consistent APIs, bug types like "forgot to check permission" or "serializer missing a field" barely exist — there's nowhere to forget.
Compared to traditional boilerplate-heavy code where every line is a chance to slip up, Ash moves that risk into a framework that's been tested by the community.
Bottom line
If you're building a serious backend with real business logic — many entities, many roles, many API endpoints — and your team can learn Elixir, Ash is worth it. You'll get a system that's easier to maintain, writes less code, and scales without big refactors.
But if your team is all PHP/Node/Python, or the project is an MVP that needs to ship fast — writing traditional code in a language the team already knows is still the more reasonable choice.
No framework is "the best" for everyone, but Ash is genuinely interesting if you're tired of boilerplate and want code that tells the business story rather than the infrastructure. Check it out at ash-hq.org