I am using the Ash Framework from Alembic to build-out an Ecommerce Store app on Github.
Ash Framework approaches the TCO (Total Cost of Ownership) argument implicit in LLM AI prompt or vibe coding from a different direction. Ash Framework promises to reduce TCO, with installers, generators, declaration and derivation. Ash gives the TCO of code gen with the precision of SDLC. So lets use the Ash Framework tools!
Project Github repo for source code.
This is not a recipe, or a how-to, instead it is some notes for myself and others.
I bought the excellent book from Pragprog, but also wanted to memorialize some of my personal experiences and comments on the framework.
Ash Framework: Create Declarative Elixir Web Apps
Having worked as a Customer Success Manager for a fintech and ERP SaaS which had its own framework, I have a feel for the business logic driving the Ash Framework and wanted to work through parts of the tool-chain in public.
Its so over, or is it?
Tesla's former chief AI scientist Andrej Kaparthy recently wrote on X about a new form of software engineering called "vibe coding";
There's a new kind of coding I call "vibe coding", where you fully give in to the vibes, embrace exponentials, and forget that the code even exists. It's possible because the LLMs (e.g. Cursor Composer w Sonnet) are getting too good. Also I just talk to Composer with SuperWhisper so I barely even touch the keyboard. I ask for the dumbest things like "decrease the padding on the sidebar by half" because I'm too lazy to find it. I "Accept All" always, I don't read the diffs anymore. When I get error messages I just copy paste them in with no comment, usually that fixes it. The code grows beyond my usual comprehension, I'd have to really read through it for a while. Sometimes the LLMs can't fix a bug so I just work around it or ask for random changes until it goes away. It's not too bad for throwaway weekend projects, but still quite amusing. I'm building a project or webapp, but it's not really coding - I just see stuff, say stuff, run stuff, and copy paste stuff, and it mostly works.
AI coding, or more precisely, LLM coding (what is hyped at the extreme as "vibe coding") is allegedly much less expensive, the Total Cost of Ownership (TCO) of AI / LLM vibe coding is much lower than traditional hands-on coding. I know of no actual research on this vibe coding claim.
Allegedly "its so over" for precision software engineering with LLM "search, summarize, copy, paste, test, adjust" or "vibe coding". Vibe coding is when a prompt engineer guides a coding agent that clarifies prompts, generates code, runs code and tests it, adjusts in a cycle with the human vibe engineer who does not touch code. This might work for onboarding of projects, or MVP's but I cannot see it working for precision and controlled environments.
Ash Framework as accelerated precision vibe coding?
Ash Framework approaches the TCO (Total Cost of Ownership) argument from a different direction. Ash Framework promises to reduce TCO, with installers, generators, declaration and derivation. So lets use them!
Ash Framework offers precision AND efficiency, something "vibe coding" via prompts cannot offer right now.
I want to lean hard into the rapid and lean promise of Ash Framework.
Installer
I went for a kitchen-sink approach to installing Ash.
From Ash HQ I choose the Phoenix LiveView preset, with ALL the things. Probably overkill, but lets roll with it!
sh <(curl 'https://ash-hq.org/new/store?install=phoenix') \
&& cd store && mix igniter.install ash_phoenix \
ash_graphql ash_json_api ash_postgres ash_sqlite \
ash_authentication ash_authentication_phoenix ash_money \
ash_csv ash_admin ash_oban ash_state_machine \
ash_double_entry ash_archival ash_paper_trail cloak \
ash_cloak --auth-strategy password \
--auth-strategy magic_link --yes && mix ash.setup
The installer created a code project with mix.exs
that looks like this.
defmodule Store.MixProject do
use Mix.Project
def project do
[
app: :store,
version: "0.1.0",
elixir: "~> 1.14",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
consolidate_protocols: Mix.env() != :dev,
aliases: aliases(),
deps: deps()
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {Store.Application, []},
extra_applications: [:logger, :runtime_tools]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:ex_money_sql, "~> 1.0"},
{:bcrypt_elixir, "~> 3.0"},
{:picosat_elixir, "~> 0.2"},
{:open_api_spex, "~> 3.0"},
{:oban, "~> 2.0"},
{:ash_cloak, "~> 0.1"},
{:cloak, "~> 1.0"},
{:ash_paper_trail, "~> 0.5"},
{:ash_archival, "~> 1.0"},
{:ash_double_entry, "~> 1.0"},
{:ash_state_machine, "~> 0.2"},
{:ash_oban, "~> 0.3"},
{:ash_admin, "~> 0.12"},
{:ash_csv, "~> 0.9"},
{:ash_money, "~> 0.1"},
{:ash_authentication_phoenix, "~> 2.0"},
{:ash_authentication, "~> 4.0"},
{:ash_sqlite, "~> 0.2"},
{:ash_postgres, "~> 2.0"},
{:ash_json_api, "~> 1.0"},
{:ash_graphql, "~> 1.0"},
{:ash_phoenix, "~> 2.0"},
{:ash, "~> 3.0"},
{:igniter, "~> 0.5", only: [:dev, :test]},
{:phoenix, "~> 1.7.19"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.10"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.0.0"},
{:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.1.1",
sparse: "optimized",
app: false,
compile: false,
depth: 1},
{:swoosh, "~> 1.5"},
{:finch, "~> 0.13"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.5"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ash.setup", "assets.setup", "assets.build", "run priv/repo/seeds.exs"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ash.setup --quiet", "test"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["tailwind store", "esbuild store"],
"assets.deploy": [
"tailwind store --minify",
"esbuild store --minify",
"phx.digest"
]
]
end
end
Adjust :dev live reload
During initial phase of dev I made Ash generated Resources, Domains and LiveViews live reloadable. I found myself adding and editing Domains, Resources & LiveViews and wondering why nothing worked, no live reloading by default. Weird.
Also, I needed to add a *_live
regex pattern for Ash Phoenix LiveView generated files. Perhaps later when I have the basic Domains & Resources completed I can turn this off? Does it become a performance issue when I have many Domains and Resources? For a first time user, perhaps Igniter needs to adjust those?.
# Watch static and templates for browser reloading.
config :store, StoreWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/store_web/(controllers|live|.*_live|components)/.*(ex|heex)$",
~r"lib/store/.*/.*(ex|heex)$",
~r"lib/store/.*(ex|heex)$"
]
]
Accounts
Everything depends on users, and Ash has nice Account current_user
generators.
mix igniter.install ash_authentication_phoenix
Adds, inserts and updates the code needed for Accounts in Phoenix, defaults to current_user
Password strategy is suggested default, roll with that.
mix ash_authentication.add_strategy password
Domains & Resources
I want to persist to Postgres (Pgvector actually for embedding via Ollama LLM), so I run the Ash Postgress extension again, unsure if I needed to do this again, as it came with the installer, but I found it useful to see what it suggests etc.
Igniter is cool
Igniter is generally accepted as a widely useful tool inside and outside of Ash Framework and is being used in other projects such as BeaconCMS from Dockyard (which is not an Ash Framework shop afaik).
Igniter is a second generation generator, in that its repeatable, first generation generators where a once and done affair. Igniter allows generators to be run whenever you want, "Eg you don’t need to add things now just because you might need them later, they are composable ( as long as you didn’t drift the hook points so hard it can’t find them)".
Igniter = composable + additive generation approach ( not subtractive)
Lets run the Ash Postgres installer.
mix igniter.install ash_postgres
Domain & Resource Seller
Generates a Domain file
mix ash.gen.domain Store.Seller
Which I then manually update with Resource action alias helpers.
defmodule Store.Seller do
use Ash.Domain, otp_app: :store, extensions: [AshGraphql.Domain, AshJsonApi.Domain, AshPhoenix]
resources do
resource Store.Seller.Seller do
define(:create_seller, action: :create)
define(:update_seller, action: :update)
define(:destroy_seller, action: :destroy)
define(:read_sellers, action: :read)
define(:get_seller_by_id, action: :read, get_by: :id)
end
end
end
Generate an Ash Resource Seller
mix ash.gen.resource Store.Seller.Seller \
--default-actions read \
--uuid-primary-key id \
--attribute slug:string:required:public \
--attribute first_name:string:required:public \
--attribute last_name:string:required:public \
--attribute street1:string:required:public \
--attribute street2:string:public \
--attribute city:string:required:public \
--attribute state:string:required:public \
--attribute zip:string:required:public \
--attribute country:string:required:public \
--attribute notes:string:public \
--attribute x:string:public \
--attribute facebook:string:public \
--attribute instagram:string:public \
--attribute domain:string:public \
--attribute email:string:required:public \
--attribute phone:string:public \
--attribute status:string:required:public \
--attribute role:string:required:public \
--attribute stripe_id:string:required:public \
--relationship has_many:product:Store.Product.Product \
--timestamps \
--extend postgres,graphql,json_api
Creates a Seller Resource
defmodule Store.Seller.Seller do
use Ash.Resource,
otp_app: :store,
domain: Store.Seller,
extensions: [AshGraphql.Resource, AshJsonApi.Resource],
data_layer: AshPostgres.DataLayer
graphql do
type(:seller)
end
json_api do
type("seller")
end
postgres do
table "sellers"
repo Store.Repo
end
attributes do
uuid_primary_key(:id)
attribute :slug, :string do
allow_nil?(false)
public?(true)
end
attribute :first_name, :string do
allow_nil?(false)
public?(true)
end
attribute :last_name, :string do
allow_nil?(false)
public?(true)
end
attribute :street1, :string do
allow_nil?(false)
public?(true)
end
attribute :street2, :string do
public?(true)
end
attribute :city, :string do
allow_nil?(false)
public?(true)
end
attribute :state, :string do
allow_nil?(false)
public?(true)
end
attribute :zip, :string do
allow_nil?(false)
public?(true)
end
attribute :country, :string do
allow_nil?(false)
public?(true)
end
attribute :notes, :string do
public?(true)
end
attribute :x, :string do
public?(true)
end
attribute :facebook, :string do
public?(true)
end
attribute :instagram, :string do
public?(true)
end
attribute :domain, :string do
public?(true)
end
attribute :email, :string do
allow_nil?(false)
public?(true)
end
attribute :phone, :string do
public?(true)
end
attribute :status, :string do
allow_nil?(false)
public?(true)
end
attribute :role, :string do
allow_nil?(false)
public?(true)
end
attribute :stripe_id, :string do
allow_nil?(false)
public?(true)
end
timestamps()
end
relationships do
has_many :product, Store.Product.Product
end
actions do
# defaults [:create, :read, :update, :destroy]
default_accept([
:slug,
:first_name,
:last_name,
:street1,
:street2,
:city,
:state,
:zip,
:country,
:notes,
:x,
:facebook,
:instagram,
:domain,
:email,
:phone,
:status,
:role,
:stripe_id
])
create :create do
accept([
:slug,
:first_name,
:last_name,
:street1,
:street2,
:city,
:state,
:zip,
:country,
:notes,
:x,
:facebook,
:instagram,
:domain,
:email,
:phone,
:status,
:role,
:stripe_id
])
end
update :update do
accept([:slug])
end
read :read do
primary?(true)
end
destroy :destroy do
end
end
end
NOTE: Personally I find this is too much whitespace and would like to have a compact version, thats way too much scrolling for me.
Ash Resource Product
Generate an Ash Resource for an ecommerce Product
mix ash.gen.resource Store.Product.Product \
--default-actions read \
--uuid-primary-key id \
--attribute sku:string:required:public \
--attribute name:string:required:public \
--attribute slug:string:required:public \
--attribute subtitle:string:public \
--attribute description:string:required:public \
--attribute featured_image:string:required:public \
--attribute images:map:public \
--attribute featured:boolean:required:public \
--attribute order:integer:public \
--attribute stripe_id:string:required:public \
--attribute price:decimal:required:public \
--relationship belongs_to:seller:Store.Seller.Seller \
--timestamps \
--extend postgres,graphql,json_api,AshAdmin.Resource
Generate and run migrations etc
mix ash.codegen create_seller_product
NOTE: I encountered a workflow sequencing issue, do I need to create a relationship Resource before? This is my first Ash Resource so I kludged the relationship by commenting out, running migrations and then adding back relationship. This possibly will cause problems in the future and I may need to fix this. I hope to catch the sequencing issue in my next code generation cycle.
Ash LiveView Generator
The book defers to the official LiveView docs and has LiveView snippets etc, and talks you through Ash related logic for LiveView.
I don't have time right now so I jumped outside the book narrative and used the Ash LiveView generator.
mix ash_phoenix.gen.live --domain Store.Seller --resource Store.Seller.Seller --resourceplural sellers
Creates a Show LiveView
defmodule StoreWeb.SellerLive.Show do
use StoreWeb, :live_view
@impl true
def render(assigns) do
~H"""
<.header>
Seller {@seller.id}
<:subtitle>This is a seller record from your database.</:subtitle>
<:actions>
<.link patch={~p"/sellers/#{@seller}/show/edit"} phx-click={JS.push_focus()}>
<.button>Edit seller</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Id">{@seller.id}</:item>
<:item title="Slug">{@seller.slug}</:item>
<:item title="First name">{@seller.first_name}</:item>
<:item title="Last name">{@seller.last_name}</:item>
<:item title="Street1">{@seller.street1}</:item>
<:item title="Street2">{@seller.street2}</:item>
<:item title="City">{@seller.city}</:item>
<:item title="State">{@seller.state}</:item>
<:item title="Zip">{@seller.zip}</:item>
<:item title="Country">{@seller.country}</:item>
<:item title="Notes">{@seller.notes}</:item>
<:item title="X">{@seller.x}</:item>
<:item title="Facebook">{@seller.facebook}</:item>
<:item title="Instagram">{@seller.instagram}</:item>
<:item title="Domain">{@seller.domain}</:item>
<:item title="Email">{@seller.email}</:item>
<:item title="Phone">{@seller.phone}</:item>
<:item title="Status">{@seller.status}</:item>
<:item title="Role">{@seller.role}</:item>
<:item title="Stripe">{@seller.stripe_id}</:item>
</.list>
<.back navigate={~p"/sellers"}>Back to sellers</.back>
<.modal
:if={@live_action == :edit}
id="seller-modal"
show
on_cancel={JS.patch(~p"/sellers/#{@seller}")}
>
<.live_component
module={StoreWeb.SellerLive.FormComponent}
id={@seller.id}
title={@page_title}
action={@live_action}
current_user={@current_user}
seller={@seller}
patch={~p"/sellers/#{@seller}"}
/>
</.modal>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:seller, Ash.get!(Store.Seller.Seller, id, actor: socket.assigns.current_user))}
end
defp page_title(:show), do: "Show Seller"
defp page_title(:edit), do: "Edit Seller"
end
Add the routes to router and we are up and running!
If I was running a team I would look to create a streamlined "playbook" or procedural document for a tired and stressed engineer to work through at 3am. Thats my approach to playbooks as a complement to the lovingly detailed and careful narrative of the Ash Framework book.