elixir

Domains and Resources in Ash for Elixir

Shankar Dhanasekaran

Shankar Dhanasekaran on

Domains and Resources in Ash for Elixir

If you’ve been on the fence about trying the Ash Framework, this post is for you.

We’re going to explore why Ash is a game changer for Elixir developers by building something complex, real, but surprisingly elegant.

We’ll create AshTherapy, an AI-powered therapeutic chat service that handles conversations, messages, and user interactions — all powered by Ash.

Before we go further, let’s first answer the question: what exactly is Ash?

What Is Ash for Elixir?

Ash is a business domain modeling framework for Elixir. It works seamlessly with Phoenix to simplify the part of our app that deals with business logic — our data, relationships, and rules.

Think of it like this:

  • Phoenix handles the technical layer — HTTP, sockets, and controllers.
  • Ash handles the business layer — entities, relationships, and actions.

Together, they help us build fast and model correctly, with less boilerplate and more focus on what really matters.

The AshTherapy Use Case

Now that we know what Ash is, let’s see it in action. We’ll bring the concepts of domains and resources to life through our demo app, AshTherapy.

Here’s how it works:

  1. A user visits the site and starts a new conversation.
  2. They set a goal or motive — for example, “I want to handle work stress better.”
  3. They then exchange messages with an AI companion that responds thoughtfully.
  4. This back-and-forth continues, forming a guided digital therapy session.
AshTherapy - AI-powered therapeutic chat service

To model this in Ash, we’ll define three key resources:

  • User — the person using the service
  • Conversation — the therapy session container
  • Message — each exchange in the session (user or AI)

All three live inside a single domain: AshTherapy.Therapy.

In this first post, we’ll focus on defining these resources and understanding how the domain ties them together.

Check Out the Code

Before we dive deeper, a quick note. In this post, we won’t walk through how to create an Ash domain or resource step-by-step — that part is already well covered in the official documentation.

Our focus here is on understanding what domains and resources mean in Ash and how they work together, which is even more important when we’re just getting started.

If we check out the AshTherapy codebase (branch: post-1), we’ll find everything set up and ready to follow along with the examples in this post. We’ll take time here to explain the code in that project.

To learn how to create a new domain or resource from scratch, we can refer to the official Ash HexDocs.

Shell
mix ash.generate.domain MyApp.MyDomain
Shell
mix ash.generate.resource MyApp.MyDomain.MyResource

With that out of the way, let’s explore the User, Conversation, and Message resources that make up our AshTherapy domain.

Ash Domains

An Ash domain is a logical container that groups related resources together. It keeps our business logic organized and provides a clean boundary for everything that belongs to a specific part of our application — like Therapy, Accounts, or Billing.

While domains might seem simple at first, they become much more powerful as our app grows. That’s because many of Ash’s advanced features — like policies, code interfaces, and automated APIs — are built on top of domains.

We’ll create a single AshTherapy.Therapy domain, which looks like this:

Elixir
defmodule AshTherapy.Therapy do use Ash.Domain resources do resource AshTherapy.Therapy.User resource AshTherapy.Therapy.Conversation resource AshTherapy.Therapy.Message end end

We use the resources DSL to wrap the individual resources managed by this Ash domain.

Understanding Ash Resources

An Ash resource represents a business entity — such as a User, Conversation, or Message in our AshTherapy app. Together with domains, resources form the business engine that powers our application.

Every resource in Ash is just a normal Elixir module — until we add this line:

Elixir
use Ash.Resource

That one line turns a plain module into a powerful declarative resource. It gives us access to Domain Specific Language (DSL) blocks like:

  • attributes: Define fields and data types
  • relationships: Define associations between resources
  • actions: Define what operations can be performed

There are many other DSLs available, such as policies and identities, but in this post, we’ll focus on these three foundational blocks.

Resource Structure

Like Ash domains, resources are also plain Elixir modules that consume macros from the Ash library, which grant them their superpowers. The basic structure of a resource looks like this:

Elixir
defmodule AshTherapy.Therapy.Message do use Ash.Resource, data_layer: AshPostgres.DataLayer postgres do # Database table and Repo configuration end attributes do # Resource attributes end relationships do # Resource associations end actions do # Resource actions end end

Now, instead of showing full files for Conversation and Message, let’s highlight just the most interesting snippets from the AshTherapy project to understand each part.

Attributes

Here, we describe what information a conversation holds using the attribute DSL. This might seem familiar to Ecto, where we use the field macro. However, the attribute macro is more powerful and offers a lot more options.

Elixir
attributes do uuid_primary_key :id attribute :goal, :string do allow_nil? false description "User’s goal or motive for starting this session." end attribute :status, :atom do constraints one_of: [:active, :closed] default :active description "Lifecycle state of the session." end attribute :started_at, :utc_datetime do default &DateTime.utc_now/0 end end

Declaring Relationships

Every conversation belongs to a user and contains messages. That’s expressed like this in the Conversation resource:

Elixir
relationships do belongs_to :user, AshTherapy.Therapy.User, allow_nil?: true has_many :messages, AshTherapy.Therapy.Message end

This tells Ash how our resources connect — it automatically handles loading, filtering, and association behaviors internally.

Declaring Business Actions

Now comes the heart of the resource: actions. Actions tell Ash what operations can be performed — creation, updates, reads, etc. Instead of writing explicit Elixir functions, we configure behavior through macros.

Elixir
actions do defaults [:read] create :start_conversation do accept [:goal, :user_id] change set_attribute(:started_at, &DateTime.utc_now/0) end read :get_conversation_with_messages do argument :id, :uuid, allow_nil?: false filter expr(id == ^arg(:id)) prepare build(load: [:messages]) end end

Let’s break down the start_conversation action:

  • It’s a create action for the Conversation resource.
  • It accepts only two inputs — goal and user_id.
  • It automatically sets started_at to the current time.
  • The status is already handled by its default value (:active).

If we compare this to a traditional Phoenix and Ecto setup, we’d have to:

  1. Define a schema.
  2. Write a changeset.
  3. Write a function like create_conversation inside a context.
  4. Call Repo.insert(changeset) manually.

With Ash, all of that is replaced by a simple, expressive declaration.

Reading Data with Built-in Preloading

The get_conversation_with_messages action shows another elegant pattern:

Fetch this conversation by ID and preload all related messages.

Elixir
read :get_conversation_with_messages do argument :id, :uuid, allow_nil?: false filter expr(id == ^arg(:id)) prepare build(load: [:messages]) end

No need to define query joins or preload manually. Ash interprets the relationships we declared earlier and does the heavy lifting.

In short:

  • We define what our business entities look like.
  • We describe how they relate.
  • We declare what can be done with them.

Ash then turns those configurations into complete, consistent, and database-backed operations — no repetitive boilerplate required.

Now that you’ve seen what Ash does at a high level, let’s take a deeper look at the philosophy that makes it so effective.

Declare Once, Derive the Rest

You might have heard the variation on Ash’s tagline:

Declare once, derive the rest.

This is not just a catchy phrase — it captures the very heart of what makes Ash powerful.

Let’s unpack that a bit.

If we look at an Ecto schema, it already feels declarative. We describe the shape of our data, the fields, their types — and that’s a great starting point. Conceptually, Ecto begins in the right place: it encourages us to describe what data looks like instead of writing procedural code.

But here’s where Ecto stops — and where Ash continues the journey.

In Ecto, that declarative schema doesn’t get reused elsewhere. After defining a schema, we still have to:

  • Write migrations manually, repeating much of the same information in SQL-like form.
  • Write context functions that perform create, read, or update operations.
  • Write controllers or APIs to expose those actions externally.

All of that is work we do again and again — even though the intent was already declared in the schema.

Frameworks like Rails or Django solved this years ago by reusing their models to drive migrations, validations, and APIs automatically. Ash brings that level of cohesion — and more — into the Elixir ecosystem.

Here’s how Ash builds on Ecto’s declarative foundation and takes it several steps further:

  • Once a resource is defined, Ash can generate migrations automatically. No need to manually write create table(...) or update migrations when attributes change. Even renames, removals, and relationship updates can be derived automatically.
  • The same resource definition also powers API generation. We can expose JSON:API, GraphQL, or RPC (like TypeScript remote procedure calls) in a single line of configuration — no need to write controllers or context functions.
  • All of this information — attributes, actions, relationships — stays consistent across the system because it comes from one single source of truth: the resource definition itself.

This is what is meant by “declare once, derive the rest”. We define our business logic in one place, and Ash intelligently derives everything else — migrations, APIs, validations, documentation — directly from that single declaration.

Quick Comparison: Ecto vs Ash

Here's a quick look at Ecto and Ash's features side-by-side:

FeatureEctoAsh
Schema DefinitionDeclarativeDeclarative
MigrationsManualAuto-generated
Context FunctionsManualDerived from Actions
API LayerHandwrittenAuto-generated
ReuseLimitedSystem-wide reuse

Ash turns your domain model into the single source of truth across all layers.

Playing with Ash in IEx

Let’s now play with what we’ve built and see Ash in action.

If you haven’t already cloned the repository, do it now and set up your project:

Shell
git clone https://github.com/devcarrots/ash_therapy.git cd ash_therapy mix deps.get mix ash.codegen mix ash.migrate iex -S mix

Understanding Ash.create and Ash.read

So far, we have seen how to declare actions, but we haven't executed them yet. Ash provides simple functions to interact with these resource actions:

  • Ash.create/3 (or Ash.create!/3) executes a create action on a resource. You provide the resource module, the action name, and the data map for that action.
  • Ash.read/3 (or Ash.read!/3) executes a read action, usually returning filtered data.

Create a User

Before we can start a conversation, we need a user. I have already defined a register_user action in our User resource. It’s a simple create action that accepts a user’s name and stores a new record, similar in structure to the start_conversation action we explored earlier.

Elixir
iex> user = Ash.create!( AshTherapy.Therapy.User, :register_user, %{name: "Nisha"} )

This executes the register_user action on the User resource and stores a record in the database. The returned struct includes an auto-generated UUID and timestamps.

Start a Conversation

Next, let’s start a conversation for this user. A conversation needs a goal (the user’s motive) and the user_id.

Elixir
iex> conversation = Ash.create!( AshTherapy.Therapy.Conversation, :start_conversation, %{goal: "I want to handle work stress", user_id: user.id} )

This calls the start_conversation action, automatically setting the started_at timestamp. The status defaults to :active.

Send a Message

Now that we have a conversation set up, let’s send the first message. I have defined a send_message action in the message resource to take care of that. It's an action that can both send messages as a :user and also as the :ai agent (by setting the right :role attribute), as shown below.

Elixir
iex> Ash.create!( AshTherapy.Therapy.Message, :send_message, %{ content: "I feel anxious every Monday morning.", conversation_id: conversation.id # The default value of attribute `:role` is `:user`. So the below line is not needed. # role: :user } )

The send_message action defaults the role to :user, sets the current timestamp, and saves the message.

To simulate an AI reply, we can reuse the same action, but explicitly set the role:

Elixir
iex> Ash.create!( AshTherapy.Therapy.Message, :send_message, %{ content: "That sounds difficult. What usually triggers this feeling?", conversation_id: conversation.id, # We set the `:role` to `:ai` when we want to override the default. role: :ai } )

This reuse of the same action demonstrates how defaults and configuration keep Ash concise and flexible.

Fetch the Conversation Messages

Now let’s see the conversation and all the messages.

Elixir
Ash.read!( AshTherapy.Therapy.Conversation, :get_conversation_with_messages, %{id: conversation.id} )

The get_conversation_with_messages action loads the conversation and preloads all associated messages in one step. You’ll see a neat struct showing the conversation goal, status, and a list of messages (both from the user and AI).

And that's it!

Wrapping Up

In this post, we:

  • Explored what Ash is and how it complements Phoenix
  • Built three foundational resources: User, Conversation, and Message
  • Introduced attributes, relationships, and actions
  • Learned how Ash embodies the principle “declare once, derive the rest”
  • Tried out our domain interactively in IEx by calling Ash actions as if they were small, reusable business commands.

Happy Ash-ing! 👋

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Shankar Dhanasekaran

Shankar Dhanasekaran

With over 18 years of experience in web technologies and a deep commitment to the Elixir community, Shankar has trained developers worldwide through hands-on workshops and is a keynote speaker at ElixirConfEU. He also co-founded Dhāraṇā, a mindfulness-driven platform uniting technology and consciousness.

All articles by Shankar Dhanasekaran

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps