Ash Framework: The Coordination Problem
Not organization but coordination.
Part of a series on the Ash Framework book. Previous: My Misconceptions
Ash Framework
Learn how to use Ash Framework to build robust, maintainable Elixir applications. This book covers the principles and practices of domain-driven design with Ash's declarative approach.
A traditional framework provides its own stack, such as an ORM, view layer, routing system, and its own approach to validation, authorization, and testing. It defines how an application should be structured and expects us to work within those boundaries. This brings consistency, but at the cost of flexibility.
Ash is different. Instead of building its own stack, it coordinates well known libraries in the Elixir ecosystem.
What Ash Discovered
Ash recognized that much of the work in Elixir applications comes down to wiring libraries together. Defining attributes, setting up validations, exposing APIs, and enforcing policies. More importantly, those layers all depend on the same core information.
The Elixir ecosystem had already embraced Domain Driven Design through patterns like Phoenix contexts and Ecto repos. Ash builds on that foundation, letting us declare resources and actions at the domain level and generating the code needed to keep everything in sync.
The Coordination Problem
Adding a field is easy. Writing field :dob, :date
takes seconds. The real cost is keeping all the layers aligned.
- Ecto: schemas and migrations
- LiveView: form logic and authorization
- JSON API: endpoints and serialization
- GraphQL: schema types and resolvers
- Tests, docs, and fixtures for each of them
Schema definition
field :dob, :date
Validation
|> cast(attrs, [:name, :dob])
|> validate_past_date(:dob)
Authorization
def can_edit_dob?(user, artist) do
# Who can update DOB? Same rules as name? Different?
end
Interfaces
LiveView
<%= if can_edit_dob?(@current_user, @artist) do %>
<%= input f, :dob, type: :date %>
<% end %>
JSON API
def show(conn, %{"id" => id}) do
artist = get_artist!(id)
dob = if can_view_dob?(conn.assigns.current_user, artist),
do: artist.dob, else: nil
render(conn, "show.json", %{artist: %{artist | dob: dob}})
end
GraphQL
field :dob, :date do
resolve fn artist, _, %{context: %{current_user: user}} ->
if can_view_dob?(user, artist), do: {:ok, artist.dob}, else: {:ok, nil}
end
end
Each module makes sense on its own, but miss a step and things drift. A form allows edits that should not be permitted. An API leaks information. A migration goes untested. The complexity is not in the code itself. It is in keeping everything aligned.
Ash’s Solution
# In Artist resource
attributes do
attribute :dob, :date do
public? true
end
end
# Authorization through action policies (Chapter 6)
policies do
policy action(:update) do
authorize_if actor_attribute_equals(:role, :admin)
end
end
Then we run a few commands:
mix ash.codegen
mix ash_postgres.generate_migrations
mix ecto.migrate
Ash generates:
- Ecto migrations and schemas
- JSON and GraphQL endpoints
- LiveView form helpers
- Authorization logic
Instead of editing scattered files across multiple layers, we change the resource definition and let Ash handle the coordination. There’s no lock-in. We can still drop down into the primary libraries or even write an adapter to integrate a new one.
This softens the convention cliff found in other frameworks. With Ash, stepping outside simply means returning to familiar tools.