Elixir Ash Framework Mastery: Declarative Application Development
Goal
After completing these projects, you will deeply understand declarative application development in Elixir using the Ash Framework. You will internalize how resources, actions, and data layers work together to eliminate boilerplate while maintaining full control over your domain logic. You will master the art of defining complex business rules through policies, calculations, and aggregates rather than imperative code. Most importantly, you will understand why declarative frameworks fundamentally change how we think about application architecture, enabling you to build production-ready applications with authentication, authorization, and multiple API interfaces in a fraction of the time traditional approaches require.
Why Elixir Ash Framework Matters
The Problem Ash Solves
Traditional web application development follows a repetitive pattern: define a schema, write migrations, create context modules with CRUD functions, add validation logic, implement authorization checks, build API endpoints, and repeat for every resource. This leads to:
- Boilerplate explosion: 80% of code is structural, not business logic
- Inconsistent patterns: Different developers implement similar concepts differently
- Authorization scattered everywhere: Permission checks spread across controllers, contexts, and views
- API generation tedium: REST and GraphQL endpoints require separate implementations
- Testing complexity: Each layer requires separate test coverage
The Declarative Revolution
Ash represents a paradigm shift from “tell the computer what to do” to “describe what you want.” Instead of writing imperative code, you declare your domain model, and Ash generates the implementation.
Traditional Approach Ash Declarative Approach
┌─────────────────────────┐ ┌─────────────────────────┐
│ Schema Module │ │ Resource Definition │
│ ├── Ecto.Schema │ │ ├── Attributes │
│ ├── changeset/2 │ │ ├── Relationships │
│ └── validations │ │ ├── Actions │
├─────────────────────────┤ │ ├── Calculations │
│ Context Module │ │ ├── Aggregates │
│ ├── create_*/2 │ │ ├── Policies │
│ ├── get_*/1 │ │ └── Validations │
│ ├── update_*/2 │ └──────────┬──────────────┘
│ ├── delete_*/1 │ │
│ └── list_*/0 │ ▼
├─────────────────────────┤ ┌─────────────────────────┐
│ Controller/LiveView │ │ Ash Generates: │
│ ├── authorization │ │ ├── CRUD operations │
│ ├── parameter handling │ │ ├── Authorization │
│ └── API serialization │ │ ├── Validation │
├─────────────────────────┤ │ ├── JSON:API endpoints │
│ Authorization Module │ │ ├── GraphQL schema │
│ ├── can_*/2 functions │ │ └── Form handling │
│ └── policy checks │ └─────────────────────────┘
└─────────────────────────┘
~500 lines per resource ~80 lines per resource
Multiple files to maintain Single source of truth
Real-World Impact
Companies using Ash Framework report:
- 60-80% reduction in boilerplate code
- Consistent API behavior across all resources
- Authorization logic co-located with resource definitions
- Automatic API generation (REST, GraphQL) from single source
- Built-in introspection for documentation and tooling
The Ash Ecosystem
┌─────────────────────────────────┐
│ Your Application │
│ (Domain Logic & Resources) │
└─────────────┬───────────────────┘
│
┌─────────────▼───────────────────┐
│ Ash Core │
│ Resources │ Actions │ Domains │
└─────────────┬───────────────────┘
│
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Data Layers │ │ Extensions │ │ Interfaces │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ AshPostgres │ │ AshAuthentication│ │ AshPhoenix │
│ AshSqlite │ │ AshStateMachine │ │ AshJsonApi │
│ AshCubDB │ │ AshPaperTrail │ │ AshGraphQL │
│ Ash.DataLayer.Ets│ │ AshArchival │ │ AshAdmin │
│ Ash.DataLayer.Mnesia│ │ AshOban │ └─────────────────┘
└─────────────────┘ │ AshAI │
└─────────────────┘
Who Uses Ash
- Startups: Rapid prototyping with production-ready architecture
- Enterprises: Consistent patterns across large teams
- Agencies: Faster client delivery with maintainable code
- Open Source: Growing community with active development
Prerequisites & Background Knowledge
Essential Prerequisites
Before starting these projects, you should be comfortable with:
- Elixir fundamentals (pattern matching, processes, GenServers)
- Phoenix basics (controllers, LiveView, contexts)
- Ecto fundamentals (schemas, changesets, queries)
- SQL basics (tables, relationships, queries)
Self-Assessment Questions
Answer these before starting. If you struggle, review the prerequisite topics first:
- What happens when you call
GenServer.call/3vsGenServer.cast/2? - How do Ecto changesets validate data?
- What’s the difference between
has_manyandbelongs_toin Ecto? - How does Phoenix.PubSub enable real-time features?
- What are LiveView assigns and how do they trigger re-renders?
Development Environment
You’ll need:
- Elixir 1.15+ (1.17 recommended)
- Erlang/OTP 26+ (27 recommended)
- PostgreSQL 14+
- Phoenix 1.7+
- A code editor with Elixir support (VS Code with ElixirLS recommended)
Time Investment
- Quick exploration: 2-3 weeks (Projects 1-8)
- Solid foundation: 6-8 weeks (Projects 1-15)
- Full mastery: 12-16 weeks (All projects)
Core Concept Analysis
1. Resources: The Heart of Ash
Resources are the fundamental building blocks in Ash. They define your domain entities including their attributes, relationships, actions, and policies.
┌─────────────────────────────────────────────────────────────┐
│ Ash Resource │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Attributes │ │ Relationships│ │ Actions │ │
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
│ │ :id │ │ belongs_to │ │ :create │ │
│ │ :title │ │ has_many │ │ :read │ │
│ │ :status │ │ has_one │ │ :update │ │
│ │ :inserted_at│ │ many_to_many│ │ :destroy │ │
│ └─────────────┘ └─────────────┘ │ :custom_* │ │
│ └─────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │Calculations │ │ Aggregates │ │ Policies │ │
│ ├─────────────┤ ├─────────────┤ ├─────────────┤ │
│ │ :full_name │ │ :count │ │ authorize_if│ │
│ │ :age │ │ :sum │ │ forbid_if │ │
│ │ :formatted │ │ :first │ │ bypass │ │
│ └─────────────┘ │ :list │ └─────────────┘ │
│ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Data Layer │ │
│ │ (Postgres, SQLite, ETS, Mnesia, Custom...) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
2. Actions: Defining Behavior
Actions define what operations can be performed on a resource. Unlike traditional CRUD, Ash encourages semantic action names that reflect business intent.
┌──────────────────────────────────────────────────────────────────┐
│ Action Types │
├────────────────┬────────────────┬────────────────┬───────────────┤
│ :create │ :read │ :update │ :destroy │
├────────────────┼────────────────┼────────────────┼───────────────┤
│ Creates new │ Queries data │ Modifies │ Removes │
│ records │ with filters │ existing │ records │
│ │ and sorts │ records │ │
├────────────────┴────────────────┴────────────────┴───────────────┤
│ │
│ Semantic Actions (Recommended): │
│ │
│ Instead of: Use: │
│ ├── :create → :register, :submit, :draft │
│ ├── :read → :list_active, :search, :get_by_slug │
│ ├── :update → :publish, :approve, :archive │
│ └── :destroy → :cancel, :soft_delete, :purge │
│ │
└───────────────────────────────────────────────────────────────────┘
Action Lifecycle:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Input → Authorize → Validate → Change → Persist → Notify │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ params policies built-in changes data layer pubsub │
│ args actors custom hooks transaction notifiers │
│ context bypass atomic │
│ │
└─────────────────────────────────────────────────────────────────┘
3. Domains: Organizing Resources
Domains group related resources and expose their actions. Think of them as bounded contexts in Domain-Driven Design.
┌─────────────────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Accounts │ │ Blog │ │
│ │ (Domain) │ │ (Domain) │ │
│ ├─────────────────┤ ├─────────────────┤ │
│ │ ├── User │ │ ├── Post │ │
│ │ ├── Token │ │ ├── Comment │ │
│ │ └── Role │ │ └── Tag │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ └──────────┬───────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Shared Code │ │
│ │ (Cross-domain) │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Domain Responsibilities:
- Expose actions to the outside world
- Define default options for queries/changesets
- Provide domain-level authorization
- Enable code organization by business capability
4. Data Layers: Persistence Abstraction
Data layers abstract the storage mechanism, allowing the same resource definition to work with different backends.
┌─────────────────────────────────────────────────────────────────┐
│ Data Layer Abstraction │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Resource Definition (unchanged across data layers) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ attributes do │ │
│ │ attribute :name, :string │ │
│ │ end │ │
│ │ │ │
│ │ actions do │ │
│ │ defaults [:create, :read, :update, :destroy] │ │
│ │ end │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ AshPostgres │ │ Ash.Ets │ │ AshSqlite │ │
│ ├───────────────┤ ├───────────────┤ ├───────────────┤ │
│ │ SQL queries │ │ In-memory │ │ File-based │ │
│ │ Transactions │ │ Fast tests │ │ Embedded │ │
│ │ Migrations │ │ Prototyping │ │ Single-user │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
│ │
│ Data Layer Capabilities: │
│ ├── transact: Can wrap operations in transactions │
│ ├── upsert: Can insert or update atomically │
│ ├── aggregate: Can compute aggregates in the database │
│ ├── join: Can join resources in queries │
│ ├── filter: Can apply filters at the data layer │
│ └── sort: Can sort results at the data layer │
│ │
└─────────────────────────────────────────────────────────────────┘
5. Extensions: Expanding Capabilities
Extensions add functionality to resources through composition rather than inheritance.
┌─────────────────────────────────────────────────────────────────┐
│ Extension Architecture │
├─────────────────────────────────────────────────────────────────┤
│ │
│ use Ash.Resource, │
│ extensions: [ │
│ AshPostgres.DataLayer, ← Data persistence │
│ AshAuthentication, ← User authentication │
│ AshStateMachine, ← State transitions │
│ AshPaperTrail, ← Audit logging │
│ AshArchival, ← Soft deletes │
│ ] │
│ │
│ Extension Composition: │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Base Resource │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Postgres │ │ Auth │ │ State │ │ Audit │ │ │
│ │ │ Layer │ │ Actions │ │ Machine │ │ Trail │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ │ │ │ │ │ │ │
│ │ └────────────┴────────────┴────────────┘ │ │
│ │ │ │ │
│ │ Combined Resource │ │
│ │ (All extensions work together) │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
6. Query Flow: From Request to Response
Understanding how queries flow through Ash is essential for debugging and optimization.
┌─────────────────────────────────────────────────────────────────┐
│ Query Execution Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ External Request │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ API Interface │ (Phoenix, JSON:API, GraphQL) │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Ash.Query │ Build query with filters, sorts, loads │
│ │ ├── filter() │ │
│ │ ├── sort() │ │
│ │ ├── load() │ │
│ │ └── limit() │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Authorization │ Check policies, apply actor context │
│ │ ├── policies │ │
│ │ ├── actor │ │
│ │ └── authorize? │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Data Layer │ Execute against storage │
│ │ ├── translate │ (Query → SQL) │
│ │ ├── execute │ │
│ │ └── hydrate │ (Rows → Structs) │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Post-process │ Load relationships, calculations │
│ │ ├── load deps │ │
│ │ ├── calc values│ │
│ │ └── aggregates │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ Response to Client │
│ │
└─────────────────────────────────────────────────────────────────┘
Concept Summary Table
| Concept | What It Is | Why It Matters | Key DSL |
|---|---|---|---|
| Resource | Domain entity with attributes, actions, policies | Single source of truth for entity behavior | use Ash.Resource |
| Domain | Group of related resources | Organizes code by business capability | use Ash.Domain |
| Action | Operation that can be performed | Defines explicit behavior rather than implicit CRUD | actions do ... end |
| Attribute | Data field on a resource | Defines the shape of your data | attribute :name, :type |
| Relationship | Connection between resources | Enables data modeling and loading | belongs_to, has_many |
| Calculation | Derived value computed on-demand | Avoids storing redundant data | calculate :name, :type, expr(...) |
| Aggregate | Summary over related data | Efficient counts, sums, etc. | count :name, :relationship |
| Policy | Authorization rule | Declarative permissions | policy action_type(...) do ... end |
| Data Layer | Storage abstraction | Enables different backends | postgres do ... end |
| Extension | Adds capabilities to resources | Modular feature composition | extensions: [...] |
Deep Dive Reading by Concept
Official Resources
| Topic | Resource | Why Read It |
|---|---|---|
| Getting Started | Ash HexDocs Get Started | Official tutorial with Help Desk example |
| Core Concepts | What is Ash? | Foundational understanding |
| Actions | Actions Documentation | Deep dive into action types and customization |
| Relationships | Relationships Guide | All relationship types explained |
| Policies | Policies Documentation | Authorization patterns |
| Calculations | Calculations Guide | Computed fields |
| Aggregates | Aggregates Documentation | Summary data patterns |
Books
| Book | Chapters | Focus |
|---|---|---|
| Ash Framework: Create Declarative Elixir Web Apps (Rebecca Le, Zach Daniel) | All | Comprehensive guide by the framework creator |
| Programming Phoenix LiveView | Ch 1-4 | Phoenix integration foundation |
| Programming Ecto | Ch 3-6 | Understanding the data layer |
| Elixir in Action | Ch 5-8 | OTP patterns Ash builds on |
Community Resources
| Resource | URL | Focus |
|---|---|---|
| Ash HQ | ash-hq.org | Official site with guides |
| Elixir Forum | elixirforum.com | Community Q&A |
| GitHub | ash-project/ash | Source and issues |
| A Gentle Primer | jon.hk/elixir/ash | Beginner-friendly intro |
Quick Start Guide
First 48 Hours
If you’re overwhelmed by the scope of Ash, here’s what to do in your first 48 hours:
Day 1 (4 hours):
- Read What is Ash? (30 min)
- Follow the Get Started guide (2 hours)
- Complete Project 1: Installation and First Resource (1.5 hours)
Day 2 (4 hours):
- Complete Project 2: Attributes and Types (1.5 hours)
- Complete Project 3: Relationships (2 hours)
- Read Calculations and Aggregates docs (30 min)
After 48 hours, you’ll have a working Ash application with multiple resources, relationships, and understand the core concepts.
Recommended Learning Paths
Path 1: The Pragmatic Developer (4 weeks)
For developers who want practical skills quickly:
Week 1: Foundation
├── Project 1: Installation & First Resource
├── Project 2: Attributes and Types
└── Project 3: Relationships
Week 2: Actions & Data
├── Project 4: CRUD Actions
├── Project 5: Custom Actions
└── Project 6: AshPostgres Integration
Week 3: Enhancement
├── Project 7: Calculations
├── Project 8: Aggregates
└── Project 9: Filtering and Sorting
Week 4: Integration
├── Project 10: AshPhoenix Forms
├── Project 11: Authentication
└── Project 12: Authorization Policies
Path 2: The Full-Stack Builder (8 weeks)
For developers building complete applications:
Weeks 1-4: Follow Path 1
Weeks 5-6: API Layer
├── Project 13: JSON:API
├── Project 14: GraphQL
└── Project 15: Real-time with PubSub
Weeks 7-8: Production
├── Project 16: Testing Strategies
├── Project 17: Full Application Build
└── Review and refactor earlier projects
Path 3: The Framework Deep-Diver (12 weeks)
For developers who want complete mastery:
Weeks 1-8: Follow Path 2
Weeks 9-10: Advanced Topics
├── Custom data layers
├── Extension development
└── Performance optimization
Weeks 11-12: Architecture
├── Multi-tenancy patterns
├── Event sourcing with Ash
└── Complex domain modeling
Projects
Project 1: Installation and First Resource
What You’ll Build
A Phoenix application with Ash installed and a single Ticket resource using the ETS data layer for in-memory storage.
Why This Project Matters
Understanding the installation process and basic resource structure is foundational. This project establishes the mental model you’ll use throughout Ash development: define a resource, register it with a domain, and call actions through the domain.
Core Challenges
- Installing Ash dependencies correctly with Igniter
- Understanding the resource DSL structure
- Connecting resources to domains
- Calling domain functions to perform actions
Key Concepts
- Resources as domain entities
- Domains as action gateways
- The ETS data layer for prototyping
- Ash generators and Igniter
| Difficulty: Beginner | Time: 2-3 hours |
Prerequisites: Phoenix project creation, Mix dependencies
Real-World Outcome
After completing this project, you’ll have a Phoenix application where you can:
# In IEx
iex> alias MyApp.Support
iex> alias MyApp.Support.Ticket
# Create a ticket
iex> Ticket |> Ash.Changeset.for_create(:create, %{subject: "Help needed", description: "Cannot login"}) |> Ash.create!()
#Ticket<
id: "a1b2c3d4-...",
subject: "Help needed",
description: "Cannot login",
...
>
# List all tickets
iex> Ash.read!(Ticket)
[#Ticket<...>]
The Core Question You’re Answering
How does Ash’s declarative resource definition translate into runnable code, and what is the relationship between resources, domains, and the functions that operate on them?
Concepts You Must Understand First
-
What is a DSL (Domain-Specific Language)? Ash uses macros to create a specialized language for defining resources. If you can’t explain how Elixir macros work at a high level, review the Metaprogramming Elixir book.
-
What are Mix dependencies? You must understand
mix.exsdeps and how Elixir packages are installed. -
What is ETS? Erlang Term Storage is OTP’s in-memory database. Understand that ETS data doesn’t persist across restarts.
Questions to Guide Your Design
Before writing code, answer these:
- What should happen when you create a resource but don’t register it with a domain?
- Why would you use ETS instead of PostgreSQL for initial development?
- What’s the difference between
Ash.create!andAsh.create? - How do you know what attributes a resource has?
Thinking Exercise
Before starting, trace what happens when you call:
Ticket
|> Ash.Changeset.for_create(:create, %{subject: "Test"})
|> Ash.create!()
Draw a diagram showing:
- How the changeset is built
- How the domain routes the action
- How the data layer stores the record
- What gets returned
The Interview Questions They’ll Ask
- “What problem does Ash Framework solve compared to raw Ecto?”
- “Explain the relationship between a resource and a domain.”
- “Why would you use the ETS data layer?”
- “What’s the difference between
Ash.createandAsh.create!?” - “How does Ash know which actions are available on a resource?”
Hints in Layers
Hint 1 - Starting Point:
Use Igniter to install Ash: mix igniter.install ash. This handles configuration automatically.
Hint 2 - Next Level:
Create a resource with mix ash.gen.resource MyApp.Support.Ticket. Then create a domain module that uses Ash.Domain.
Hint 3 - Technical Details: Your resource needs:
use Ash.Resource, domain: MyApp.Support, data_layer: Ash.DataLayer.Ets
attributes do
uuid_primary_key :id
attribute :subject, :string
end
actions do
defaults [:create, :read]
end
Hint 4 - Verification:
Test in IEx with iex -S mix. Create a ticket, then read all tickets. If both work, your setup is correct.
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Ash fundamentals | Ash Framework (Pragmatic) | Ch 1-2 |
| Phoenix project setup | Programming Phoenix 1.4 | Ch 1 |
| Elixir metaprogramming | Metaprogramming Elixir | Ch 1-2 |
Common Pitfalls & Debugging
| Problem | Cause | Fix | Verification |
|---|---|---|---|
undefined function Ash.create!/1 |
Ash not installed properly | Run mix igniter.install ash |
mix deps.tree \| grep ash |
No domain configured |
Resource missing :domain option |
Add domain: MyApp.Support to resource |
Compile without errors |
No actions defined |
Missing actions block | Add actions do defaults [:create, :read] end |
MyApp.Support.Ticket.__ash_actions__() |
| Data doesn’t persist | ETS is in-memory only | This is expected; switch to Postgres for persistence | Restart IEx and verify data is gone |
Learning Milestones
- Basic Understanding: You can create and read resources using IEx
- Working Knowledge: You understand the resource/domain relationship and can explain it
- Deep Understanding: You can create new resources from scratch without referring to documentation
Project 2: Attributes and Types
What You’ll Build
Extend your Ticket resource with various attribute types including strings, integers, enums, timestamps, and custom types. Add proper constraints and validations.
Why This Project Matters
Attributes define the shape of your data. Understanding Ash’s type system and constraints is crucial for data integrity. Unlike Ecto where you define schema and changeset validations separately, Ash unifies these concepts in the attribute definition.
Core Challenges
- Using different attribute types (string, integer, atom, map, etc.)
- Adding constraints (required, allow_nil, min/max length)
- Creating enum types for status fields
- Working with timestamps and default values
- Understanding writable vs. non-writable attributes
Key Concepts
- Ash type system
- Attribute constraints
- Enum definitions
- Default values and computed defaults
- Private attributes
| Difficulty: Beginner | Time: 2-3 hours |
Prerequisites: Project 1 completed
Real-World Outcome
Your Ticket resource will have rich attributes:
# Create a ticket with various attributes
iex> Ticket
...> |> Ash.Changeset.for_create(:create, %{
...> subject: "Password reset",
...> description: "User forgot password",
...> priority: :high,
...> estimated_hours: 2
...> })
...> |> Ash.create!()
#Ticket<
id: "...",
subject: "Password reset",
description: "User forgot password",
status: :open, # Default value
priority: :high,
estimated_hours: 2,
inserted_at: ~U[2024-01-15 10:30:00Z],
updated_at: ~U[2024-01-15 10:30:00Z],
...
>
# Validation errors for missing required fields
iex> Ticket |> Ash.Changeset.for_create(:create, %{}) |> Ash.create()
{:error, %Ash.Error.Invalid{
errors: [
%Ash.Error.Changes.Required{field: :subject, ...}
]
}}
The Core Question You’re Answering
How does Ash’s attribute system differ from Ecto schemas, and how do constraints provide validation at the data definition level rather than in separate changeset functions?
Concepts You Must Understand First
- Elixir type specs: How Elixir types work (atoms, strings, integers)
- Ecto types: Ash builds on Ecto’s type system
- Changeset validations: Understand how Ecto validates before storage
Questions to Guide Your Design
- Should
statusbe a string or an enum? What are the tradeoffs? - When should an attribute have
allow_nil?: false? - What’s the difference between
defaultandcreate_default? - Why might you make an attribute
private?: true?
Thinking Exercise
Design the attributes for a User resource that includes:
- Email (required, unique, must be valid format)
- Name (required, min 2 characters)
- Role (enum: admin, member, guest)
- Bio (optional, max 500 characters)
- Verified status (boolean, default false)
- Timestamps
Write out the attribute definitions before implementing.
The Interview Questions They’ll Ask
- “How do you define an enum type in Ash?”
- “What’s the difference between
allow_nil?andrequired??” - “How do you set default values that are computed at runtime?”
- “What constraints are available for string attributes?”
- “How do you prevent an attribute from being set by external input?”
Hints in Layers
Hint 1 - Starting Point:
Define an enum with defmodule MyApp.Support.Ticket.Status do use Ash.Type.Enum, values: [:open, :in_progress, :closed] end
Hint 2 - Next Level:
Use constraints for validation: attribute :subject, :string, allow_nil?: false, constraints: [min_length: 5, max_length: 200]
Hint 3 - Technical Details:
attributes do
uuid_primary_key :id
attribute :subject, :string do
allow_nil? false
constraints min_length: 5, max_length: 200
end
attribute :status, MyApp.Support.Ticket.Status do
default :open
allow_nil? false
end
create_timestamp :inserted_at
update_timestamp :updated_at
end
Hint 4 - Verification: Try creating tickets with invalid data and verify you get proper error messages.
Common Pitfalls & Debugging
| Problem | Cause | Fix | Verification |
|---|---|---|---|
| Enum not found | Module not compiled | Ensure enum module is defined before resource | Code.ensure_loaded(MyApp.Support.Ticket.Status) |
| Constraint not validated | Wrong constraint name | Check docs for exact constraint names | Read error messages carefully |
| Default not applied | Using default instead of create_default |
Use create_default for runtime computation |
Create record and check value |
Project 3: Relationships
What You’ll Build
Create a complete support system with Users, Tickets, and Comments. Implement belongs_to, has_many, and many_to_many relationships.
Why This Project Matters
Relationships are the backbone of data modeling. Ash relationships work differently than Ecto - they’re first-class citizens that integrate with actions, policies, and loading. Understanding relationship loading, managing related data through actions, and querying across relationships is essential.
Core Challenges
- Defining belongs_to, has_many, and many_to_many relationships
- Managing foreign keys and source/destination attributes
- Loading relationships in queries
- Understanding relationship cardinality
- Creating records with related data
Key Concepts
- Relationship types and when to use each
- Source and destination attributes
- Relationship loading with
Ash.load - Nested data management
| Difficulty: Beginner-Intermediate | Time: 3-4 hours |
Prerequisites: Projects 1-2 completed
Real-World Outcome
# Create a user
iex> user = User |> Ash.Changeset.for_create(:create, %{email: "dev@example.com"}) |> Ash.create!()
# Create a ticket belonging to the user
iex> ticket = Ticket
...> |> Ash.Changeset.for_create(:create, %{
...> subject: "Help needed",
...> reporter_id: user.id
...> })
...> |> Ash.create!()
# Load the relationship
iex> ticket |> Ash.load!(:reporter)
#Ticket<
reporter: #User<email: "dev@example.com", ...>,
...
>
# Query tickets with related data
iex> Ticket |> Ash.Query.load(:reporter) |> Ash.read!()
[#Ticket<reporter: #User<...>, ...>]
# Access user's tickets
iex> user |> Ash.load!(:tickets)
#User<
tickets: [#Ticket<...>],
...
>
The Core Question You’re Answering
How do Ash relationships differ from Ecto associations, and how does the loading mechanism work to fetch related data efficiently?
Concepts You Must Understand First
- Database foreign keys: How relationships are stored at the database level
- Ecto associations: Basic understanding of has_many/belongs_to in Ecto
- N+1 query problem: Why relationship loading strategy matters
Questions to Guide Your Design
- Should the foreign key be on Ticket or User for a “reporter” relationship?
- How do you ensure a ticket always has a reporter?
- When would you use
has_onevshas_many? - How do you load nested relationships (ticket -> reporter -> organization)?
Thinking Exercise
Draw an ER diagram for this system:
- Users have many Tickets (as reporter)
- Tickets have many Comments
- Comments belong to a User (author) and a Ticket
- Users can be assigned to Tickets (many-to-many through assignments)
Label all foreign keys and cardinality.
The Interview Questions They’ll Ask
- “Explain the difference between belongs_to and has_many in Ash.”
- “How do you prevent N+1 queries when loading relationships?”
- “What happens if you try to delete a user who has tickets?”
- “How do you implement a many-to-many relationship?”
- “Can you have multiple relationships to the same resource?”
Hints in Layers
Hint 1 - Starting Point: Start with two resources (User, Ticket) and a single belongs_to relationship from Ticket to User.
Hint 2 - Next Level: Define both sides of the relationship:
- Ticket:
belongs_to :reporter, User - User:
has_many :tickets, Ticket, destination_attribute: :reporter_id
Hint 3 - Technical Details:
# In Ticket
relationships do
belongs_to :reporter, MyApp.Accounts.User do
allow_nil? false
attribute_type :uuid
end
end
# In User
relationships do
has_many :tickets, MyApp.Support.Ticket do
destination_attribute :reporter_id
end
end
Hint 4 - Verification: Create a ticket with a reporter, then load the reporter. Create a user, then load their tickets.
Common Pitfalls & Debugging
| Problem | Cause | Fix | Verification |
|---|---|---|---|
relationship not found |
Typo in relationship name | Check exact name in load/1 |
Resource.__ash_relationships__() |
| Missing foreign key | belongs_to doesn’t auto-create attribute | It does in Ash 3.x, check attribute exists | Resource.__ash_attributes__() |
| Circular dependency | Resources reference each other | Use string reference: belongs_to :reporter, "MyApp.Accounts.User" |
Compile without errors |
Project 4: CRUD Actions
What You’ll Build
Implement comprehensive CRUD (Create, Read, Update, Destroy) actions for your support system with validation, error handling, and action arguments.
Why This Project Matters
Actions are how the outside world interacts with your resources. Unlike traditional frameworks where you write controller actions, Ash actions are defined on the resource itself. This project teaches you how actions work, how to customize them, and how to use action arguments effectively.
Core Challenges
- Understanding default CRUD actions
- Customizing action accepts/rejects
- Using action arguments vs attributes
- Implementing action changes (before/after hooks)
- Understanding atomic vs non-atomic operations
Key Concepts
- Action types (create, read, update, destroy)
- Primary actions
- Action arguments
- Accept/reject options
- Manual actions
| Difficulty: Intermediate | Time: 3-4 hours |
Prerequisites: Projects 1-3 completed
Real-World Outcome
# Create with specific accepted attributes
iex> Ticket
...> |> Ash.Changeset.for_create(:open_ticket, %{
...> subject: "Bug report",
...> description: "App crashes on startup",
...> reporter_id: user.id
...> })
...> |> Ash.create!()
# Update only allowed fields
iex> ticket
...> |> Ash.Changeset.for_update(:assign, %{assignee_id: agent.id})
...> |> Ash.update!()
# Read with filters
iex> Ticket
...> |> Ash.Query.filter(status == :open)
...> |> Ash.read!()
# Destroy with soft-delete behavior
iex> ticket |> Ash.Changeset.for_destroy(:close) |> Ash.destroy!()
The Core Question You’re Answering
How do Ash actions provide a layer of abstraction over raw data operations, and why is it better to define many specific actions rather than few generic ones?
Concepts You Must Understand First
- Changesets: How Ash builds changesets for actions
- Action semantics: What each action type means
- Elixir pattern matching: For action arguments
Questions to Guide Your Design
- Should you have one
:updateaction or many specific ones (:assign,:close,:escalate)? - When should you use
acceptvsrejectfor attributes? - What’s the difference between an action argument and an accepted attribute?
- How do you ensure only certain fields can be changed through a specific action?
Thinking Exercise
Design the actions for a Ticket resource:
- Create actions:
:open(new tickets),:create_from_email(automated) - Read actions:
:list_open,:list_by_assignee,:search - Update actions:
:assign,:unassign,:change_priority,:add_note,:close - Destroy actions:
:delete,:archive
For each, list what attributes/arguments it should accept.
The Interview Questions They’ll Ask
- “Why does Ash encourage many specific actions instead of generic CRUD?”
- “What’s the difference between
acceptandargumentin an action?” - “How do you make an action the primary action for its type?”
- “Explain the action lifecycle: what happens before and after the data layer?”
- “How do you add side effects to an action?”
Hints in Layers
Hint 1 - Starting Point:
Start with defaults [:create, :read, :update, :destroy], then customize one at a time.
Hint 2 - Next Level: Create a specific action:
update :assign do
accept []
argument :assignee_id, :uuid, allow_nil?: false
change set_attribute(:assignee_id, arg(:assignee_id))
end
Hint 3 - Technical Details: Use changes for action logic:
create :open_ticket do
accept [:subject, :description]
change set_attribute(:status, :open)
change relate_actor(:reporter)
end
Hint 4 - Verification: Try calling each action and verify only the intended attributes can be changed.
Common Pitfalls & Debugging
| Problem | Cause | Fix | Verification |
|---|---|---|---|
| Attribute not accepted | Not in accept list |
Add to accept [:field] or use argument |
Check error message |
| Action not found | Typo or not defined | Check action name exactly | Resource.__ash_actions__() |
| Changes not applied | Missing change call |
Add change set_attribute(...) |
Read after write |
Project 5: Custom Actions and Changes
What You’ll Build
Create custom actions with complex business logic including state transitions, side effects, and multi-step workflows.
Why This Project Matters
Real applications have complex business logic that goes beyond simple CRUD. This project teaches you to encapsulate domain logic within actions using changes, validations, and manual implementations. You’ll learn when to use built-in changes vs custom change modules.
Core Challenges
- Writing custom change modules
- Implementing state machine transitions
- Adding validations that span multiple fields
- Orchestrating multi-step operations
- Using manual actions for complex logic
Key Concepts
- Custom Ash.Resource.Change modules
- Validations
- Manual actions
- State transitions
- Action composition
| Difficulty: Intermediate | Time: 4-5 hours |
Prerequisites: Project 4 completed
Real-World Outcome
# State transition with validation
iex> ticket
...> |> Ash.Changeset.for_update(:resolve, %{resolution_notes: "Fixed the bug"})
...> |> Ash.update!()
# Returns error if transition is invalid
iex> closed_ticket |> Ash.Changeset.for_update(:resolve, %{}) |> Ash.update()
{:error, %Ash.Error.Invalid{
errors: [%MyApp.Support.Ticket.InvalidTransition{
from: :closed,
to: :resolved
}]
}}
# Manual action with complex logic
iex> Support.escalate_ticket(ticket, level: 2, notify: [:manager, :oncall])
{:ok, %Ticket{priority: :urgent, escalation_level: 2}}
The Core Question You’re Answering
How do you encapsulate complex business logic within Ash’s declarative model while maintaining testability and composability?
Concepts You Must Understand First
- Elixir behaviours: How to implement callback modules
- State machines: Valid state transitions
- Ecto multi: Transaction patterns (Ash handles this, but understand why)
Questions to Guide Your Design
- When should logic go in a change module vs inline in the action?
- How do you validate that a state transition is allowed?
- What’s the difference between a validation and a change?
- When should you use a manual action vs composition of changes?
Thinking Exercise
Design a ticket escalation workflow:
- Ticket can be escalated to levels 1-3
- Each escalation sends notifications to different people
- Escalation changes priority automatically
- Cannot escalate closed tickets
- Level 3 escalation creates a linked incident record
Write pseudocode for this workflow as an Ash action with changes.
The Interview Questions They’ll Ask
- “How do you implement a custom change in Ash?”
- “What’s the difference between
changeandvalidatein an action?” - “How do you share logic between multiple actions?”
- “When would you use a manual action?”
- “How do you handle rollback logic in Ash?”
Hints in Layers
Hint 1 - Starting Point: Create a change module:
defmodule MyApp.Support.Changes.SetPriority do
use Ash.Resource.Change
def change(changeset, _opts, _context) do
Ash.Changeset.force_change_attribute(changeset, :priority, :high)
end
end
Hint 2 - Next Level: Add validations for state transitions:
validate fn changeset, _context ->
current = Ash.Changeset.get_data(changeset, :status)
new = Ash.Changeset.get_attribute(changeset, :status)
if valid_transition?(current, new) do
:ok
else
{:error, field: :status, message: "Invalid transition"}
end
end
Hint 3 - Technical Details: For manual actions:
action :escalate, :struct do
constraints instance_of: __MODULE__
argument :level, :integer
manual MyApp.Support.Actions.Escalate
end
Hint 4 - Verification: Test invalid transitions and verify proper error messages are returned.
Project 6: AshPostgres Data Layer
What You’ll Build
Migrate your support system from ETS to PostgreSQL using AshPostgres. Implement migrations, indexes, and database-specific features.
Why This Project Matters
Production applications need persistent storage. AshPostgres provides a bridge between Ash’s declarative model and PostgreSQL’s powerful features. Understanding how Ash generates migrations, handles schema changes, and optimizes queries is essential for real-world applications.
Core Challenges
- Installing and configuring AshPostgres
- Generating and running migrations
- Adding indexes for performance
- Understanding how Ash translates to SQL
- Using PostgreSQL-specific features
Key Concepts
- AshPostgres.DataLayer
- Migrations with
mix ash.codegen - Indexes and constraints
- References and foreign keys
- Custom SQL types
| Difficulty: Intermediate | Time: 3-4 hours |
Prerequisites: Projects 1-5, PostgreSQL installed
Real-World Outcome
# Generate migrations
$ mix ash.codegen initial_migration
# Run migrations
$ mix ash_postgres.migrate
# Check generated SQL
$ mix ash_postgres.migrate --dry-run
# Data persists across restarts
iex> Ticket |> Ash.Changeset.for_create(:create, %{subject: "Test"}) |> Ash.create!()
iex> # Restart IEx
iex> Ash.read!(Ticket)
[#Ticket<subject: "Test", ...>]
# Complex queries translated to efficient SQL
iex> Ticket
...> |> Ash.Query.filter(status == :open and inserted_at > ago(7, :day))
...> |> Ash.Query.sort(priority: :desc)
...> |> Ash.Query.load(:reporter)
...> |> Ash.read!()
The Core Question You’re Answering
How does Ash’s declarative resource definition translate to PostgreSQL schemas and queries, and how do you optimize database performance within the Ash model?
Concepts You Must Understand First
- PostgreSQL basics: Tables, columns, constraints, indexes
- Ecto migrations: How schema changes are managed
- SQL queries: Understanding SELECT, JOIN, WHERE
Questions to Guide Your Design
- What indexes should you add for your most common queries?
- How do you handle schema changes after initial deployment?
- When should you use database constraints vs application validations?
- How do you debug slow queries?
Thinking Exercise
Review your Ticket and User resources. List:
- All the tables that will be created
- All foreign key relationships
- Indexes you need for these queries:
- Find all open tickets
- Find tickets by reporter email
- Find recent tickets (last 7 days)
- Full-text search on ticket subject
The Interview Questions They’ll Ask
- “How does AshPostgres generate migrations?”
- “What’s the difference between
mix ash.codegenandmix ecto.gen.migration?” - “How do you add custom indexes in Ash?”
- “How do you handle database-specific column types?”
- “How does Ash handle relationship queries at the database level?”
Hints in Layers
Hint 1 - Starting Point:
Install with mix igniter.install ash_postgres. Update your repo to use AshPostgres.Repo.
Hint 2 - Next Level: Add PostgreSQL data layer to resources:
use Ash.Resource,
domain: MyApp.Support,
data_layer: AshPostgres.DataLayer
postgres do
table "tickets"
repo MyApp.Repo
end
Hint 3 - Technical Details: Add indexes:
postgres do
table "tickets"
repo MyApp.Repo
custom_indexes do
index [:status]
index [:inserted_at]
index [:reporter_id, :status]
end
end
Hint 4 - Verification: Run migrations, create data, restart IEx, and verify data persists.
Common Pitfalls & Debugging
| Problem | Cause | Fix | Verification |
|---|---|---|---|
| Migration fails | Missing references | Ensure related resources are migrated first | Check migration order |
| Slow queries | Missing indexes | Add indexes for filter/sort columns | EXPLAIN ANALYZE in psql |
| Type mismatch | Ash type vs Postgres type | Use migration_types for custom mappings |
Check migration file |
Project 7: Calculations
What You’ll Build
Add calculated fields to your resources including expression-based calculations, function calculations, and calculations with arguments.
Why This Project Matters
Calculations derive values from existing data without storing them. They’re essential for display formatting, computed fields, and business metrics. Ash calculations are powerful because they can be filtered and sorted on, unlike simple virtual fields.
Core Challenges
- Writing expression-based calculations
- Creating function-based calculations
- Calculations with arguments
- Loading calculations efficiently
- Filtering and sorting on calculations
Key Concepts
- Ash expressions
- Calculation types
- Arguments in calculations
- Calculation context
- SQL expression translation
| Difficulty: Intermediate | Time: 3-4 hours |
Prerequisites: Project 6 completed
Real-World Outcome
# Expression calculation
iex> User
...> |> Ash.Query.load(:full_name)
...> |> Ash.read!()
[#User<full_name: "John Doe", ...>]
# Calculation with argument
iex> Ticket
...> |> Ash.Query.load(age: [unit: :hour])
...> |> Ash.read!()
[#Ticket<age: 48, ...>]
# Filter on calculation
iex> Ticket
...> |> Ash.Query.filter(age(unit: :day) > 7)
...> |> Ash.read!()
[#Ticket<...>] # Only tickets older than 7 days
# Sort on calculation
iex> User
...> |> Ash.Query.sort(:full_name)
...> |> Ash.read!()
[#User<full_name: "Alice Smith">, #User<full_name: "Bob Jones">]
The Core Question You’re Answering
How do Ash calculations provide computed values that can be loaded on-demand, filtered on, and sorted by, all while maintaining database efficiency?
Concepts You Must Understand First
- SQL expressions: How calculations translate to SQL
- Virtual fields: The concept of non-persisted values
- Ash expressions: The
expr/1macro and syntax
Questions to Guide Your Design
- Should this value be a calculation or a stored attribute?
- Can this calculation be computed in the database?
- What arguments might this calculation need?
- How will this calculation perform at scale?
Thinking Exercise
Design calculations for these requirements:
- User full name (first + last)
- Ticket age in various units (days, hours, minutes)
- User’s open ticket count
- Ticket urgency score (based on priority, age, and status)
For each, determine if it should be an expression or function calculation.
The Interview Questions They’ll Ask
- “What’s the difference between an expression and function calculation?”
- “How do you make a calculation filterable?”
- “When would you use a calculation vs an aggregate?”
- “How do calculations affect query performance?”
- “Can calculations reference relationships?”
Hints in Layers
Hint 1 - Starting Point: Simple expression calculation:
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name)
end
Hint 2 - Next Level: Calculation with arguments:
calculate :age, :integer do
argument :unit, :atom, default: :day, constraints: [one_of: [:day, :hour, :minute]]
calculation expr(
fragment("EXTRACT(EPOCH FROM (NOW() - ?))", inserted_at) /
case ^arg(:unit) do
:day -> 86400
:hour -> 3600
:minute -> 60
end
)
end
Hint 3 - Technical Details: For complex logic, use a module:
defmodule MyApp.Calculations.UrgencyScore do
use Ash.Resource.Calculation
def calculate(records, opts, context) do
Enum.map(records, fn record ->
# Complex calculation logic
base_score = priority_score(record.priority)
age_factor = age_factor(record.inserted_at)
base_score * age_factor
end)
end
end
Hint 4 - Verification: Load calculations, then filter on them, then sort on them.
Project 8: Aggregates
What You’ll Build
Add aggregate fields for counting, summing, and analyzing related data. Implement efficient queries that leverage database aggregations.
Why This Project Matters
Aggregates summarize related data (count of tickets, sum of hours, average rating). Unlike calculations, aggregates specifically work over relationships and are optimized for database-level computation. Understanding when to use aggregates vs calculations is key to performant applications.
Core Challenges
- Defining count, sum, first, list aggregates
- Filtering aggregates
- Using aggregates in filters and sorts
- Combining aggregates with calculations
- Understanding aggregate performance
Key Concepts
- Aggregate types (count, sum, first, list, exists, max, min)
- Filtered aggregates
- Aggregates on nested relationships
- Loading and filtering on aggregates
| Difficulty: Intermediate | Time: 3-4 hours |
Prerequisites: Project 7 completed
Real-World Outcome
# Count aggregate
iex> User
...> |> Ash.Query.load(:ticket_count)
...> |> Ash.read!()
[#User<ticket_count: 5, ...>]
# Filtered aggregate
iex> User
...> |> Ash.Query.load(:open_ticket_count)
...> |> Ash.read!()
[#User<open_ticket_count: 3, ...>]
# Sort by aggregate
iex> User
...> |> Ash.Query.sort(ticket_count: :desc)
...> |> Ash.Query.limit(10)
...> |> Ash.read!()
# Top 10 users by ticket count
# Filter by aggregate
iex> User
...> |> Ash.Query.filter(ticket_count > 10)
...> |> Ash.read!()
# Users with more than 10 tickets
# First aggregate (latest ticket)
iex> User
...> |> Ash.Query.load(:latest_ticket_subject)
...> |> Ash.read!()
[#User<latest_ticket_subject: "Password reset", ...>]
The Core Question You’re Answering
How do aggregates provide efficient summary data over relationships, and when should you choose aggregates over manual queries or calculations?
Concepts You Must Understand First
- SQL aggregations: GROUP BY, COUNT, SUM, MAX, MIN
- Subqueries: How aggregates translate to SQL
- N+1 problem: Why aggregates are loaded efficiently
Questions to Guide Your Design
- What summary data would be useful for your resources?
- Should you filter the aggregate or filter the main query?
- What’s the performance difference between aggregates in SQL vs Elixir?
- When would you use
:firstvs:listaggregate?
Thinking Exercise
Design aggregates for User resource:
- Total number of tickets
- Number of open tickets
- Number of closed tickets this month
- Latest ticket subject
- Average ticket resolution time (if you have resolved_at)
Write the aggregate definitions before implementing.
The Interview Questions They’ll Ask
- “What aggregate types does Ash support?”
- “How do you filter an aggregate?”
- “What’s the difference between an aggregate and a calculation?”
- “How do aggregates perform at scale?”
- “Can you have aggregates on nested relationships?”
Hints in Layers
Hint 1 - Starting Point: Basic count aggregate:
aggregates do
count :ticket_count, :tickets
end
Hint 2 - Next Level: Filtered aggregate:
aggregates do
count :open_ticket_count, :tickets do
filter expr(status == :open)
end
end
Hint 3 - Technical Details: First aggregate for latest item:
aggregates do
first :latest_ticket_subject, :tickets, :subject do
sort inserted_at: :desc
end
sum :total_estimated_hours, :tickets, :estimated_hours do
filter expr(status != :closed)
end
end
Hint 4 - Verification:
Load aggregates and verify values match manual counts. Check SQL with Ash.Query.to_sql/1.
Project 9: Filtering and Sorting
What You’ll Build
Implement comprehensive query capabilities with complex filters, sorting, pagination, and search functionality.
Why This Project Matters
Real applications need sophisticated query capabilities. Ash provides a powerful filtering DSL that translates to efficient SQL. Understanding how to build queries, combine filters, and paginate results is essential for any data-driven application.
Core Challenges
- Building complex filter expressions
- Combining multiple sort criteria
- Implementing keyset and offset pagination
- Full-text search
- Dynamic filters from user input
Key Concepts
- Ash.Query.filter expressions
- Sort specifications
- Keyset vs offset pagination
- Filter predicates (==, in, contains, etc.)
- Safe user input handling
| Difficulty: Intermediate | Time: 3-4 hours |
Prerequisites: Project 8 completed
Real-World Outcome
# Complex filter
iex> Ticket
...> |> Ash.Query.filter(
...> status in [:open, :in_progress] and
...> priority == :high and
...> inserted_at > ago(7, :day)
...> )
...> |> Ash.read!()
# Multiple sort criteria
iex> Ticket
...> |> Ash.Query.sort(priority: :desc, inserted_at: :asc)
...> |> Ash.read!()
# Keyset pagination
iex> page = Ticket
...> |> Ash.Query.sort(:inserted_at)
...> |> Ash.read!(page: [limit: 20])
iex> next_page = Ash.page!(page, :next)
# Search
iex> Ticket
...> |> Ash.Query.filter(contains(subject, "password"))
...> |> Ash.read!()
# Filter across relationships
iex> Ticket
...> |> Ash.Query.filter(reporter.email == "user@example.com")
...> |> Ash.read!()
The Core Question You’re Answering
How does Ash’s query DSL provide type-safe, efficient filtering that translates to optimized SQL while remaining composable and easy to use?
Concepts You Must Understand First
- Boolean algebra: AND, OR, NOT combinations
- SQL WHERE clauses: How filters translate
- Pagination strategies: Offset vs cursor/keyset tradeoffs
Questions to Guide Your Design
- How do you safely accept filter parameters from users?
- When should you use keyset vs offset pagination?
- How do you filter on related resources?
- What indexes do you need for your common filters?
Thinking Exercise
Design filters for a ticket search API:
- Filter by status (multiple values)
- Filter by priority
- Filter by date range
- Filter by reporter email (across relationship)
- Full-text search on subject and description
- Sort by any combination of fields
Write the Ash.Query code for each.
The Interview Questions They’ll Ask
- “What filter predicates does Ash support?”
- “How do you prevent SQL injection in dynamic filters?”
- “Explain keyset vs offset pagination tradeoffs.”
- “How do you filter across relationships?”
- “How do you implement full-text search in Ash?”
Hints in Layers
Hint 1 - Starting Point: Basic filtering:
Ticket
|> Ash.Query.filter(status == :open)
|> Ash.Query.filter(priority == :high)
|> Ash.read!()
Hint 2 - Next Level: Dynamic filters from user input:
def search(params) do
Ticket
|> Ash.Query.for_read(:list, params)
|> Ash.read!()
end
# In resource
read :list do
argument :status, {:array, :atom}
argument :priority, :atom
filter expr(
(is_nil(^arg(:status)) or status in ^arg(:status)) and
(is_nil(^arg(:priority)) or priority == ^arg(:priority))
)
end
Hint 3 - Technical Details: Keyset pagination:
read :list do
pagination keyset?: true, required?: false, default_limit: 20, max_page_size: 100
end
Hint 4 - Verification: Test with various filter combinations and verify SQL efficiency.
Project 10: AshPhoenix Forms Integration
What You’ll Build
Integrate Ash with Phoenix LiveView forms using AshPhoenix. Build interactive forms with validation, error handling, and nested data.
Why This Project Matters
AshPhoenix bridges Ash resources with Phoenix’s form handling. It provides form structs that work seamlessly with LiveView components while leveraging Ash’s validation and action system. This is how most Ash web applications handle user input.
Core Challenges
- Creating forms from Ash resources
- Handling form validation in LiveView
- Nested forms for relationships
- Form state management
- Error display and recovery
Key Concepts
- AshPhoenix.Form
- LiveView form handling
- Nested forms
- Form to action flow
- Error handling
| Difficulty: Intermediate | Time: 4-5 hours |
Prerequisites: Project 9, Phoenix LiveView basics
Real-World Outcome
# In LiveView
def mount(_params, _session, socket) do
form = AshPhoenix.Form.for_create(Ticket, :create, as: "ticket")
{:ok, assign(socket, form: to_form(form))}
end
def handle_event("validate", %{"ticket" => params}, socket) do
form = AshPhoenix.Form.validate(socket.assigns.form.source, params)
{:noreply, assign(socket, form: to_form(form))}
end
def handle_event("save", %{"ticket" => params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form.source, params: params) do
{:ok, ticket} ->
{:noreply,
socket
|> put_flash(:info, "Ticket created!")
|> push_navigate(to: ~p"/tickets/#{ticket.id}")}
{:error, form} ->
{:noreply, assign(socket, form: to_form(form))}
end
end
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:subject]} label="Subject" />
<.input field={@form[:description]} type="textarea" label="Description" />
<.input field={@form[:priority]} type="select" options={priority_options()} />
<:actions>
<.button>Create Ticket</.button>
</:actions>
</.form>
The Core Question You’re Answering
How does AshPhoenix.Form bridge the gap between Ash’s action system and Phoenix’s form handling, enabling real-time validation and seamless data submission?
Concepts You Must Understand First
- Phoenix LiveView forms:
to_form/1, form events - Changesets: Ash uses changesets internally
- LiveView assigns: State management in LiveView
Questions to Guide Your Design
- When should validation run (on change vs on submit)?
- How do you display errors for nested forms?
- How do you handle forms for existing records (updates)?
- What happens when the actor context is needed?
Thinking Exercise
Design forms for:
- Create ticket (new form)
- Edit ticket (update form)
- Create ticket with comments (nested form)
- Assign ticket to user (select from relationship)
Sketch the LiveView structure for each.
The Interview Questions They’ll Ask
- “How does AshPhoenix.Form differ from Ecto changesets in forms?”
- “How do you handle nested forms with AshPhoenix?”
- “How do you pass actor context to forms?”
- “What’s the validation flow for AshPhoenix forms?”
- “How do you handle form errors in LiveView?”
Hints in Layers
Hint 1 - Starting Point:
Install AshPhoenix: mix igniter.install ash_phoenix. Create a basic form in LiveView.
Hint 2 - Next Level: Pass actor to forms:
form = AshPhoenix.Form.for_create(Ticket, :create,
as: "ticket",
actor: socket.assigns.current_user
)
Hint 3 - Technical Details: Nested forms for relationships:
form = AshPhoenix.Form.for_create(Ticket, :create_with_comment,
forms: [
comment: [
resource: Comment,
create_action: :create
]
]
)
Hint 4 - Verification: Test full flow: render form, enter invalid data, see errors, fix errors, submit successfully.
Project 11: Authentication with AshAuthentication
What You’ll Build
Add user authentication to your application using AshAuthentication with password-based login, registration, and session management.
Why This Project Matters
Authentication is required for almost every web application. AshAuthentication integrates deeply with Ash’s resource model, providing authentication as a declarative extension rather than a separate system. This means your auth logic follows the same patterns as the rest of your application.
Core Challenges
- Installing and configuring AshAuthentication
- Adding authentication to User resource
- Implementing registration and login
- Session management with tokens
- Phoenix integration with plugs
Key Concepts
- AshAuthentication extension
- Password strategy
- Token generation and validation
- Phoenix authentication plugs
- Session vs token auth
| Difficulty: Intermediate | Time: 4-5 hours |
Prerequisites: Project 10 completed
Real-World Outcome
# Register a user
iex> User
...> |> Ash.Changeset.for_create(:register_with_password, %{
...> email: "user@example.com",
...> password: "securepassword123",
...> password_confirmation: "securepassword123"
...> })
...> |> Ash.create!()
# Sign in
iex> strategy = AshAuthentication.Info.strategy!(User, :password)
iex> AshAuthentication.Strategy.action(strategy, :sign_in, %{
...> email: "user@example.com",
...> password: "securepassword123"
...> })
{:ok, user, token}
# In Phoenix
# GET /sign-in renders login form
# POST /sign-in attempts authentication
# GET /register renders registration form
# POST /register creates account
# DELETE /sign-out logs out
The Core Question You’re Answering
How does AshAuthentication extend the resource model to provide declarative authentication, and how does this integrate with Phoenix’s request lifecycle?
Concepts You Must Understand First
- Password hashing: Why we hash, common algorithms
- Session management: Cookies, tokens, expiration
- Phoenix plugs: How authentication middleware works
Questions to Guide Your Design
- Should you use session-based or token-based authentication?
- How do you handle “remember me” functionality?
- What password requirements should you enforce?
- How do you protect routes that require authentication?
Thinking Exercise
Design your authentication flow:
- Registration with email confirmation (optional)
- Login with password
- Password reset flow
- Session timeout behavior
- Multi-device session handling
Sketch the database fields and routes needed.
The Interview Questions They’ll Ask
- “How does AshAuthentication integrate with Ash resources?”
- “What authentication strategies does AshAuthentication support?”
- “How do you protect routes in Phoenix with AshAuthentication?”
- “How are passwords stored securely?”
- “How do you implement ‘remember me’ functionality?”
Hints in Layers
Hint 1 - Starting Point:
Install: mix igniter.install ash_authentication ash_authentication_phoenix
Hint 2 - Next Level: Add to User resource:
use Ash.Resource,
extensions: [AshAuthentication]
authentication do
strategies do
password :password do
identity_field :email
hashed_password_field :hashed_password
end
end
tokens do
enabled? true
token_resource MyApp.Accounts.Token
signing_secret fn _, _ ->
Application.get_env(:my_app, :token_signing_secret)
end
end
end
Hint 3 - Technical Details: Add Phoenix routes:
# In router.ex
use AshAuthentication.Phoenix.Router
ash_authentication_routes(MyApp.Accounts.User)
Hint 4 - Verification: Register a user, sign in, access protected route, sign out, verify cannot access protected route.
Project 12: Authorization Policies
What You’ll Build
Implement comprehensive authorization using Ash policies. Define who can do what with fine-grained access control based on roles, ownership, and resource state.
Why This Project Matters
Authorization determines who can perform which actions. Ash policies are declarative and composable, co-located with resource definitions. This is fundamentally different from scattered authorization checks in controllers. Understanding policies is critical for secure applications.
Core Challenges
- Writing policy conditions
- Role-based access control
- Ownership-based permissions
- Field-level authorization
- Debugging authorization failures
Key Concepts
- Ash.Policy.Authorizer
- Policy conditions and checks
- Actors and context
- Bypass policies
- Field policies
| Difficulty: Intermediate-Advanced | Time: 5-6 hours |
Prerequisites: Project 11 completed
Real-World Outcome
# Policy passes - user can read their own tickets
iex> Ash.read!(Ticket, actor: user)
[#Ticket<reporter_id: user.id, ...>]
# Policy fails - user cannot read others' tickets (filtered out)
iex> Ash.read!(Ticket, actor: user)
[] # Other tickets filtered
# Policy passes - admin can read all tickets
iex> Ash.read!(Ticket, actor: admin)
[#Ticket<...>, #Ticket<...>, ...]
# Policy fails - unauthorized action
iex> ticket
...> |> Ash.Changeset.for_update(:assign, %{}, actor: regular_user)
...> |> Ash.update()
{:error, %Ash.Error.Forbidden{}}
# Policy passes - manager can assign
iex> ticket
...> |> Ash.Changeset.for_update(:assign, %{assignee_id: agent.id}, actor: manager)
...> |> Ash.update!()
The Core Question You’re Answering
How do Ash policies provide declarative, composable authorization that’s co-located with resources and automatically applied across all access paths?
Concepts You Must Understand First
- Authorization vs authentication: Auth = who you are, Authz = what you can do
- RBAC: Role-based access control patterns
- ABAC: Attribute-based access control
Questions to Guide Your Design
- What roles exist in your system?
- Which resources have ownership concepts?
- Should read actions filter or reject?
- What’s the default policy (allow or deny)?
Thinking Exercise
Design policies for a ticket system:
- Users can read tickets they reported
- Users can read tickets assigned to them
- Managers can read all tickets in their department
- Admins can read all tickets
- Only managers can assign tickets
- Users can only update their own ticket descriptions
- Anyone can add comments, but only to tickets they can read
Write the policy definitions before implementing.
The Interview Questions They’ll Ask
- “How do Ash policies differ from controller-based authorization?”
- “What’s the difference between
authorize_ifandforbid_if?” - “How do you implement row-level security?”
- “How do you debug authorization failures?”
- “How do policies interact with read actions?”
Hints in Layers
Hint 1 - Starting Point: Add authorizer to resource:
use Ash.Resource,
authorizers: [Ash.Policy.Authorizer]
policies do
policy action_type(:read) do
authorize_if actor_attribute_equals(:role, :admin)
end
end
Hint 2 - Next Level: Ownership-based policy:
policies do
policy action_type(:read) do
authorize_if actor_attribute_equals(:role, :admin)
authorize_if relates_to_actor_via(:reporter)
authorize_if relates_to_actor_via(:assignee)
end
end
Hint 3 - Technical Details: Custom check:
defmodule MyApp.Checks.Manager do
use Ash.Policy.SimpleCheck
def match?(actor, %{resource: Ticket} = _context, _opts) do
actor.role in [:manager, :admin]
end
end
Hint 4 - Verification: Test each policy by calling actions with different actors and verifying expected behavior.
Project 13: JSON:API with AshJsonApi
What You’ll Build
Create a fully compliant JSON:API from your Ash resources. Expose REST endpoints with proper serialization, filtering, and relationship handling.
Why This Project Matters
JSON:API is a specification for building APIs that reduces bikeshedding around response formats. AshJsonApi automatically generates compliant endpoints from your resource definitions, eliminating the need to write controllers, serializers, or documentation manually.
Core Challenges
- Installing and configuring AshJsonApi
- Exposing resource actions as endpoints
- Handling relationships in responses
- Filtering through query parameters
- OpenAPI documentation generation
Key Concepts
- JSON:API specification
- AshJsonApi router
- Route configuration
- Relationship inclusion
- OpenAPI/Swagger generation
| Difficulty: Intermediate | Time: 4-5 hours |
Prerequisites: Project 12 completed
Real-World Outcome
# List tickets
GET /api/tickets
{
"data": [
{
"type": "ticket",
"id": "uuid",
"attributes": {
"subject": "Help needed",
"status": "open"
},
"relationships": {
"reporter": {
"data": {"type": "user", "id": "uuid"}
}
}
}
]
}
# Create ticket
POST /api/tickets
Content-Type: application/vnd.api+json
{
"data": {
"type": "ticket",
"attributes": {
"subject": "New issue",
"description": "Details here"
},
"relationships": {
"reporter": {
"data": {"type": "user", "id": "uuid"}
}
}
}
}
# Filter tickets
GET /api/tickets?filter[status]=open&filter[priority]=high
# Include relationships
GET /api/tickets?include=reporter,comments
# Get OpenAPI spec
GET /api/openapi.json
The Core Question You’re Answering
How does AshJsonApi translate Ash’s declarative resource model into a fully compliant JSON:API, and how do you customize the API behavior while maintaining spec compliance?
Concepts You Must Understand First
- JSON:API spec: Resource objects, relationships, compound documents
- REST conventions: HTTP methods, status codes
- OpenAPI/Swagger: API documentation formats
Questions to Guide Your Design
- Which actions should be exposed via API?
- How should relationships be serialized?
- What filters should be available?
- How do you version your API?
Thinking Exercise
Design your API endpoints:
- GET /api/tickets (list, filtered)
- POST /api/tickets (create)
- GET /api/tickets/:id (show)
- PATCH /api/tickets/:id (update)
- DELETE /api/tickets/:id (delete)
- GET /api/tickets/:id/comments (related)
- POST /api/tickets/:id/comments (create related)
What JSON:API features will each use?
The Interview Questions They’ll Ask
- “What is the JSON:API specification?”
- “How does AshJsonApi generate endpoints from resources?”
- “How do you handle authentication in AshJsonApi?”
- “How do you customize serialization?”
- “How do you generate OpenAPI documentation?”
Hints in Layers
Hint 1 - Starting Point:
Install: mix igniter.install ash_json_api
Hint 2 - Next Level: Add to resource:
use Ash.Resource,
extensions: [AshJsonApi.Resource]
json_api do
type "ticket"
routes do
base "/tickets"
get :read
index :list
post :create
patch :update
delete :destroy
end
end
Hint 3 - Technical Details: Configure router:
defmodule MyAppWeb.Router do
use AshJsonApi.Router,
domains: [MyApp.Support],
json_schema: "/json_schema",
open_api: "/openapi"
end
Hint 4 - Verification: Test endpoints with curl or Postman. Verify JSON:API compliance with a validator.
Project 14: GraphQL with AshGraphQL
What You’ll Build
Create a GraphQL API from your Ash resources with queries, mutations, and subscriptions.
Why This Project Matters
GraphQL provides flexible querying where clients request exactly what they need. AshGraphQL automatically generates a GraphQL schema from your resources, including proper types, queries, mutations, and input validation. This eliminates the typical boilerplate of GraphQL implementations.
Core Challenges
- Installing and configuring AshGraphQL
- Generating queries and mutations
- Handling relationships and nested loading
- Custom fields and resolvers
- Subscriptions for real-time updates
Key Concepts
- GraphQL schema generation
- Queries and mutations
- Input types
- Relationship loading
- Absinthe integration
| Difficulty: Intermediate | Time: 4-5 hours |
Prerequisites: Project 12 completed, GraphQL basics
Real-World Outcome
# Query tickets with relationships
query {
tickets(filter: {status: {eq: OPEN}}, sort: [{field: INSERTED_AT, order: DESC}]) {
id
subject
status
priority
reporter {
id
email
}
comments {
id
body
author {
email
}
}
}
}
# Create ticket mutation
mutation {
createTicket(input: {
subject: "Bug report"
description: "App crashes"
priority: HIGH
}) {
result {
id
subject
}
errors {
field
message
}
}
}
# Subscription
subscription {
ticketCreated {
id
subject
reporter {
email
}
}
}
The Core Question You’re Answering
How does AshGraphQL leverage Ash’s introspectable resource model to generate a complete GraphQL schema with proper types, queries, mutations, and validation?
Concepts You Must Understand First
- GraphQL basics: Queries, mutations, subscriptions, types
- Absinthe: Elixir’s GraphQL library
- Schema design: How GraphQL schemas work
Questions to Guide Your Design
- Which actions should be exposed as queries/mutations?
- How should relationships be exposed?
- Should you enable subscriptions?
- How do you handle authentication in GraphQL?
Thinking Exercise
Design your GraphQL schema:
- What queries are needed? (list, get, search)
- What mutations? (create, update, close, assign)
- What input types?
- What return types?
- Should field names match attributes or be transformed?
Write out the expected schema.
The Interview Questions They’ll Ask
- “How does AshGraphQL generate the schema from resources?”
- “How do you handle errors in GraphQL mutations?”
- “How do you implement authentication in AshGraphQL?”
- “What’s the N+1 problem and how does AshGraphQL handle it?”
- “How do you add custom resolvers?”
Hints in Layers
Hint 1 - Starting Point:
Install: mix igniter.install ash_graphql
Hint 2 - Next Level: Add to resource:
use Ash.Resource,
extensions: [AshGraphQL.Resource]
graphql do
type :ticket
queries do
get :get, :read
list :list, :list
end
mutations do
create :create, :create
update :update, :update
destroy :destroy, :destroy
end
end
Hint 3 - Technical Details: Configure schema:
defmodule MyAppWeb.Schema do
use Absinthe.Schema
use AshGraphQL, domains: [MyApp.Support, MyApp.Accounts]
query do
end
mutation do
end
end
Hint 4 - Verification: Use GraphiQL to explore schema and test queries/mutations.
Project 15: Real-Time with PubSub and Notifiers
What You’ll Build
Implement real-time features using Ash notifiers and Phoenix.PubSub. Build live updates for ticket changes and new comments.
Why This Project Matters
Modern applications need real-time updates. Ash notifiers provide a declarative way to publish events when resources change. Combined with Phoenix.PubSub and LiveView, you can build reactive UIs that update automatically when data changes.
Core Challenges
- Configuring Ash.Notifier.PubSub
- Publishing events on actions
- Subscribing to topics in LiveView
- Handling real-time updates
- Managing topic patterns
Key Concepts
- Ash.Notifier.PubSub
- Phoenix.PubSub integration
- Topic patterns
- LiveView subscriptions
- Event payloads
| Difficulty: Intermediate | Time: 4-5 hours |
Prerequisites: Project 10, 14 completed
Real-World Outcome
# In resource - publish on actions
notifiers do
pubsub MyApp.PubSub
publish :create, ["tickets", :created]
publish :update, ["tickets", :id, :updated]
publish_all :update, ["tickets", :updated]
end
# In LiveView - subscribe and handle
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "tickets:created")
Phoenix.PubSub.subscribe(MyApp.PubSub, "tickets:updated")
end
{:ok, assign(socket, tickets: list_tickets())}
end
def handle_info({:create, %Ash.Notifier.Notification{data: ticket}}, socket) do
{:noreply, update(socket, :tickets, fn tickets -> [ticket | tickets] end)}
end
def handle_info({:update, %Ash.Notifier.Notification{data: ticket}}, socket) do
{:noreply, update(socket, :tickets, fn tickets ->
Enum.map(tickets, fn t ->
if t.id == ticket.id, do: ticket, else: t
end)
end)}
end
The Core Question You’re Answering
How do Ash notifiers provide a declarative way to publish events on resource changes, and how do you build reactive UIs that respond to these events?
Concepts You Must Understand First
- Phoenix.PubSub: Pub/sub messaging in Elixir
- LiveView subscriptions: How LiveView handles external messages
- Topic patterns: Designing topic hierarchies
Questions to Guide Your Design
- What events should be published?
- What topic pattern makes sense?
- Who should receive which notifications?
- How do you handle notification payloads?
Thinking Exercise
Design your notification system:
- Ticket created → notify all agents
- Ticket updated → notify reporter and assignee
- Comment added → notify ticket participants
- Ticket assigned → notify new assignee
What topics and patterns do you need?
The Interview Questions They’ll Ask
- “How does Ash’s PubSub notifier work?”
- “What’s the difference between
publishandpublish_all?” - “How do you handle notifications within transactions?”
- “How do you design topic patterns for multi-tenant apps?”
- “How do you test real-time notifications?”
Hints in Layers
Hint 1 - Starting Point: Add notifier to resource:
use Ash.Resource,
notifiers: [Ash.Notifier.PubSub]
Hint 2 - Next Level: Configure publications:
pub_sub do
module MyApp.PubSub
prefix "tickets"
publish :create, ["created"]
publish :update, ["updated", :id]
end
Hint 3 - Technical Details: Subscribe in LiveView:
def mount(_params, _session, socket) do
if connected?(socket) do
:ok = Phoenix.PubSub.subscribe(MyApp.PubSub, "tickets:created")
:ok = Phoenix.PubSub.subscribe(MyApp.PubSub, "tickets:updated:#{ticket_id}")
end
{:ok, socket}
end
Hint 4 - Verification: Open two browser tabs, create/update in one, verify changes appear in other.
Project 16: Testing Ash Resources
What You’ll Build
Comprehensive test suites for your Ash resources covering actions, policies, calculations, and integration scenarios.
Why This Project Matters
Testing Ash resources requires understanding what to test and how. Some things (like Ash internals) don’t need testing, but your business logic, policies, and custom code do. This project teaches testing patterns specific to Ash applications.
Core Challenges
- Setting up test infrastructure
- Testing actions with various inputs
- Testing policies with different actors
- Testing calculations and aggregates
- Integration testing with Phoenix
Key Concepts
- Test data generation
- Action testing patterns
- Policy testing
- Ash.Test helpers
- ExUnit async testing
| Difficulty: Intermediate | Time: 4-5 hours |
Prerequisites: Projects 1-15 understanding
Real-World Outcome
defmodule MyApp.Support.TicketTest do
use MyApp.DataCase, async: true
alias MyApp.Support.Ticket
alias MyApp.Accounts.User
describe "create action" do
test "creates ticket with valid attributes" do
user = create_user!()
assert {:ok, ticket} =
Ticket
|> Ash.Changeset.for_create(:create, %{
subject: "Test ticket",
description: "Description",
reporter_id: user.id
})
|> Ash.create()
assert ticket.subject == "Test ticket"
assert ticket.status == :open
end
test "fails without required subject" do
user = create_user!()
assert {:error, error} =
Ticket
|> Ash.Changeset.for_create(:create, %{
reporter_id: user.id
})
|> Ash.create()
assert Ash.Error.has_error?(error, field: :subject)
end
end
describe "policies" do
test "user can read own tickets" do
user = create_user!()
ticket = create_ticket!(reporter: user)
assert {:ok, [found]} =
Ticket
|> Ash.Query.filter(id == ^ticket.id)
|> Ash.read(actor: user)
assert found.id == ticket.id
end
test "user cannot read others' tickets" do
user1 = create_user!()
user2 = create_user!()
ticket = create_ticket!(reporter: user1)
assert {:ok, []} =
Ticket
|> Ash.Query.filter(id == ^ticket.id)
|> Ash.read(actor: user2)
end
end
describe "calculations" do
test "age calculation returns correct value" do
ticket = create_ticket!(inserted_at: DateTime.add(DateTime.utc_now(), -2, :day))
ticket = Ash.load!(ticket, :age)
assert ticket.age == 2
end
end
end
The Core Question You’re Answering
What testing patterns are effective for Ash applications, and how do you test declarative resource definitions while avoiding testing the framework itself?
Concepts You Must Understand First
- ExUnit basics: Test structure, assertions, setup
- DataCase: Database test isolation
- Test factories: Generating test data
Questions to Guide Your Design
- What needs testing vs what’s framework behavior?
- How do you generate consistent test data?
- How do you test policies in isolation?
- How do you test async behavior?
Thinking Exercise
Design test cases for:
- Ticket creation (success, validation errors)
- Ticket state transitions (valid, invalid)
- Policy enforcement (owner, admin, stranger)
- Calculations (with various inputs)
- Aggregates (correct counts)
What fixtures/factories do you need?
The Interview Questions They’ll Ask
- “What should you test in an Ash resource?”
- “How do you test policies effectively?”
- “How do you set up test data for Ash tests?”
- “Should you test calculations that use expressions?”
- “How do you test real-time notifications?”
Hints in Layers
Hint 1 - Starting Point: Set up DataCase with Ash-aware database wrapping per AshPostgres testing guide.
Hint 2 - Next Level: Create test factories:
defmodule MyApp.Factory do
def create_user!(attrs \\ %{}) do
User
|> Ash.Changeset.for_create(:create, Map.merge(%{
email: "user#{System.unique_integer()}@test.com",
password: "password123",
password_confirmation: "password123"
}, attrs))
|> Ash.create!()
end
end
Hint 3 - Technical Details: Test assertions:
# Check for specific error
assert Ash.Error.has_error?(error, field: :subject, message: "is required")
# Check policy failure
assert {:error, %Ash.Error.Forbidden{}} = result
Hint 4 - Verification:
Run mix test and ensure all tests pass. Check coverage.
Project 17: Full Application - Help Desk System
What You’ll Build
A complete help desk application combining all concepts: resources, actions, policies, calculations, aggregates, Phoenix integration, APIs, real-time updates, and comprehensive testing.
Why This Project Matters
This capstone project integrates everything you’ve learned into a production-ready application. You’ll face the real challenges of combining features, handling edge cases, and building a cohesive user experience. This is where understanding deepens into mastery.
Core Challenges
- Domain modeling for a real application
- Complex policy requirements
- Multiple user interfaces (web, API)
- Real-time features
- Production considerations
Key Concepts
- Full application architecture
- Multi-domain organization
- Feature integration
- Production patterns
| Difficulty: Advanced | Time: 10-15 hours |
Prerequisites: All previous projects
Real-World Outcome
A fully functional help desk with:
Web Interface (Phoenix LiveView):
- User registration and authentication
- Dashboard showing ticket counts and status
- Ticket list with filtering, sorting, pagination
- Ticket detail with comments and history
- Real-time updates when tickets change
- Agent assignment interface
- Manager reporting dashboard
JSON:API:
- Full CRUD for tickets via REST
- Relationship handling
- Filtering and pagination
- OpenAPI documentation
GraphQL API:
- Queries for tickets, users
- Mutations for all actions
- Subscriptions for real-time
Authorization:
- Customers see only their tickets
- Agents see assigned tickets
- Managers see all tickets in department
- Admins see everything
The Core Question You’re Answering
How do you architect a production Ash application that combines multiple domains, interfaces, and real-time features while maintaining code quality and testability?
Application Requirements
Users:
- Registration, login, password reset
- Roles: customer, agent, manager, admin
- Profile management
Tickets:
- Create, update, close, reopen
- Priority levels
- Status workflow (open → in_progress → resolved → closed)
- Assignment to agents
- SLA tracking
Comments:
- Add comments to tickets
- Internal notes (visible only to agents)
- File attachments (optional)
Departments:
- Organize agents and tickets
- Department managers
- Cross-department escalation
Reports:
- Ticket volume by time period
- Resolution time metrics
- Agent performance
Questions to Guide Your Design
- How do you structure domains for this application?
- What resources belong in each domain?
- How do tickets flow through the workflow?
- What policies enforce role-based access?
- What real-time updates are needed?
Thinking Exercise
Before coding, design:
- Domain structure (which resources in which domain)
- Resource relationships (ER diagram)
- Action list for each resource
- Policy matrix (who can do what)
- API design (endpoints/queries)
- Real-time event design
Implementation Milestones
- Week 1: Core resources (User, Ticket, Comment)
- Week 2: Authentication, authorization policies
- Week 3: Phoenix LiveView interface
- Week 4: APIs (JSON:API, GraphQL)
- Week 5: Real-time updates, refinement, testing
Hints in Layers
Hint 1 - Starting Point: Create separate domains:
# lib/my_app/accounts.ex - User, Token, Role
# lib/my_app/support.ex - Ticket, Comment
# lib/my_app/organization.ex - Department
Hint 2 - Next Level: Use generators to scaffold:
mix ash.gen.resource MyApp.Accounts.User
mix ash.gen.resource MyApp.Support.Ticket --extend postgres
mix ash.gen.resource MyApp.Support.Comment --extend postgres
Hint 3 - Technical Details: Structure your LiveView:
lib/my_app_web/live/
├── ticket_live/
│ ├── index.ex
│ ├── show.ex
│ ├── form_component.ex
│ └── comment_component.ex
├── dashboard_live.ex
└── auth/
├── login_live.ex
└── register_live.ex
Hint 4 - Verification: Create comprehensive integration tests covering main user flows.
Common Pitfalls & Debugging
| Problem | Cause | Fix |
|---|---|---|
| Circular domain dependencies | Resources referencing across domains | Use string references, reorganize domains |
| N+1 queries | Missing relationship preloading | Use Ash.Query.load/2 |
| Policy confusion | Complex rules interacting | Test policies in isolation first |
| Real-time not working | Transaction timing | Notifications are sent after transaction |
| Slow tests | Not using async | Use async: true with proper isolation |
Project Comparison Table
| Project | Difficulty | Time | Core Skill | Dependencies |
|---|---|---|---|---|
| P1: Installation & First Resource | Beginner | 2-3h | Setup, basics | None |
| P2: Attributes and Types | Beginner | 2-3h | Data modeling | P1 |
| P3: Relationships | Beginner-Int | 3-4h | Data modeling | P1-2 |
| P4: CRUD Actions | Intermediate | 3-4h | Behavior | P1-3 |
| P5: Custom Actions | Intermediate | 4-5h | Business logic | P4 |
| P6: AshPostgres | Intermediate | 3-4h | Persistence | P1-5 |
| P7: Calculations | Intermediate | 3-4h | Derived data | P6 |
| P8: Aggregates | Intermediate | 3-4h | Summary data | P7 |
| P9: Filtering/Sorting | Intermediate | 3-4h | Querying | P8 |
| P10: AshPhoenix Forms | Intermediate | 4-5h | UI integration | P9 |
| P11: Authentication | Intermediate | 4-5h | Security | P10 |
| P12: Authorization | Int-Advanced | 5-6h | Security | P11 |
| P13: JSON:API | Intermediate | 4-5h | API design | P12 |
| P14: GraphQL | Intermediate | 4-5h | API design | P12 |
| P15: PubSub/Notifiers | Intermediate | 4-5h | Real-time | P10, P14 |
| P16: Testing | Intermediate | 4-5h | Quality | P1-15 |
| P17: Full Application | Advanced | 10-15h | Integration | All |
Recommendation
For Beginners to Ash
Start with Projects 1-6 to build a solid foundation. These cover the core concepts that everything else builds on. Don’t rush - understanding resources, actions, and the data layer is essential.
For Phoenix Developers New to Ash
Focus on understanding how Ash differs from traditional Phoenix patterns. Pay special attention to:
- Resources vs Ecto schemas + contexts
- Actions vs controller logic
- Policies vs scattered authorization
- AshPhoenix.Form vs Ecto changesets
For Production Applications
Prioritize these projects:
- P12 (Authorization) - Security is non-negotiable
- P16 (Testing) - Quality assurance
- P6 (PostgreSQL) - Production persistence
- P11 (Authentication) - User management
Learning Path Summary
┌─────────────────────────────────────────┐
│ Week 1-2 │
│ P1 → P2 → P3 → P4 → P5 → P6 │
│ (Core Resources & Data Layer) │
└───────────────────┬─────────────────────┘
│
┌───────────────────▼─────────────────────┐
│ Week 3-4 │
│ P7 → P8 → P9 → P10 │
│ (Queries & UI Integration) │
└───────────────────┬─────────────────────┘
│
┌───────────────────▼─────────────────────┐
│ Week 5-6 │
│ P11 → P12 → P13 → P14 │
│ (Auth & APIs) │
└───────────────────┬─────────────────────┘
│
┌───────────────────▼─────────────────────┐
│ Week 7-8 │
│ P15 → P16 → P17 │
│ (Real-time, Testing, Capstone) │
└─────────────────────────────────────────┘
Summary
| Project | What You Build | Key Skill Gained | Time |
|---|---|---|---|
| P1 | Basic resource | Ash fundamentals | 2-3h |
| P2 | Rich attributes | Data modeling | 2-3h |
| P3 | Related resources | Relationship modeling | 3-4h |
| P4 | CRUD operations | Action design | 3-4h |
| P5 | Custom workflows | Business logic encapsulation | 4-5h |
| P6 | Postgres integration | Production persistence | 3-4h |
| P7 | Calculated fields | Derived data | 3-4h |
| P8 | Summary data | Efficient aggregations | 3-4h |
| P9 | Query builder | Data access patterns | 3-4h |
| P10 | Phoenix forms | UI integration | 4-5h |
| P11 | User auth | Identity management | 4-5h |
| P12 | Access control | Security policies | 5-6h |
| P13 | REST API | JSON:API spec | 4-5h |
| P14 | GraphQL API | Flexible queries | 4-5h |
| P15 | Real-time updates | Reactive patterns | 4-5h |
| P16 | Test suites | Quality assurance | 4-5h |
| P17 | Full help desk | Everything combined | 10-15h |
Resources
Official Documentation
- Ash HQ - Official documentation and guides
- HexDocs - API documentation
- Get Started Guide - Official tutorial
Books
- Ash Framework: Create Declarative Elixir Web Apps - Pragmatic Programmers
Community
- GitHub - Source code and issues
- Elixir Forum - Community discussions
- Discord - Real-time chat
Tutorials
- A Gentle Primer to Ash - Beginner-friendly introduction
- Alembic Blog - Practical tutorials
Last updated: 2025-12-29