Ash Framework - jak zbudować system ERP w połowie czasu
Typowy system ERP ma 30-50 encji biznesowych: klienci, zamówienia, produkty, faktury, magazyn, pracownicy, uprawnienia. Dla każdej encji programista pisze: schemat bazy, changeset, kontekst (CRUD), controller, widoki, polityki dostępu, walidacje. Pomnóż 50 encji × 7 warstw = 350 modułów do napisania ręcznie.
Ash Framework redukuje to do 50 definicji zasobów, z których generuje się reszta automatycznie. Schemat bazy, API, walidacja, autoryzacja, agregacje - wszystko zadeklarowane w jednym miejscu.
Co to jest Ash Framework
Ash to deklaratywny framework do modelowania domeny biznesowej w Elixirze. Zamiast pisać kod imperatywny („pobierz z bazy, zwaliduj, zapisz, zwróć"), deklarujesz co Twoja domena robi - a Ash generuje jak.
# Tradycyjne podejście Phoenix: ~120 linii w 4 plikach
# 1. Schema + changeset (order.ex)
# 2. Context z CRUD (orders.ex)
# 3. Controller (order_controller.ex)
# 4. Polityki dostępu (rozrzucone po plugach)
# Ash: ~60 linii w 1 pliku - reszta generowana automatycznie
defmodule MyApp.Sales.Order do
use Ash.Resource,
domain: MyApp.Sales,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "orders"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :number, :string, allow_nil?: false, public?: true
attribute :total_price, :decimal, allow_nil?: false, public?: true
attribute :status, :atom do
constraints one_of: [:draft, :confirmed, :paid, :shipped, :completed, :cancelled]
default :draft
public? true
end
attribute :notes, :string, public?: true
attribute :shipping_date, :date, public?: true
timestamps()
end
relationships do
belongs_to :customer, MyApp.Sales.Customer, public?: true
has_many :items, MyApp.Sales.OrderItem, public?: true
end
actions do
defaults [:read, :destroy]
create :create do
accept [:customer_id, :notes]
change {MyApp.Sales.Changes.GenerateOrderNumber, []}
end
update :confirm do
accept []
change set_attribute(:status, :confirmed)
validate attribute_equals(:status, :draft)
end
update :ship do
accept [:shipping_date]
change set_attribute(:status, :shipped)
validate attribute_equals(:status, :paid)
validate {MyApp.Sales.Validations.FutureDate, attribute: :shipping_date}
end
end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:create) do
authorize_if actor_attribute_equals(:role, :sales)
authorize_if actor_attribute_equals(:role, :admin)
end
policy action(:confirm) do
authorize_if actor_attribute_equals(:role, :manager)
authorize_if actor_attribute_equals(:role, :admin)
end
policy action(:ship) do
authorize_if actor_attribute_equals(:role, :warehouse)
authorize_if actor_attribute_equals(:role, :admin)
end
end
calculations do
calculate :item_count, :integer, expr(count(items))
end
aggregates do
sum :items_total, :items, :line_total
end
endZ tej jednej definicji Ash automatycznie generuje:
- Schemat bazy danych (migracje przez AshPostgres)
- Operacje CRUD z walidacją
- Polityki autoryzacji per akcja i per rola
- Agregacje i kalkulacje
- Interfejs do użycia z LiveView, GraphQL lub JSON API
Anatomia zasobu Ash
Atrybuty - struktura danych
attributes do
uuid_primary_key :id
attribute :name, :string do
allow_nil? false # NOT NULL
public? true # Widoczny w API
constraints min_length: 3, max_length: 200
end
attribute :email, :string do
allow_nil? false
public? true
constraints match: ~r/^[^\s]+@[^\s]+\.[^\s]+$/
end
attribute :price, :decimal do
allow_nil? false
public? true
constraints min: 0, max: 1_000_000
end
attribute :status, :atom do
constraints one_of: [:active, :inactive, :suspended]
default :active
public? true
end
attribute :metadata, :map, default: %{}, public?: true
timestamps() # inserted_at + updated_at
endConstraints definiujesz raz, a obowiązują wszędzie - w formularzach, API, imporcie danych. Nie ma możliwości, żeby jeden endpoint walidował inaczej niż drugi.
Relacje - powiązania między zasobami
relationships do
belongs_to :customer, MyApp.Sales.Customer do
public? true
allow_nil? false # Zamówienie musi mieć klienta
end
has_many :items, MyApp.Sales.OrderItem do
public? true
end
has_many :events, MyApp.Sales.OrderEvent do
public? true
end
many_to_many :tags, MyApp.Sales.Tag do
through MyApp.Sales.OrderTag
public? true
end
endAkcje - operacje biznesowe
Tu jest największa różnica względem tradycyjnego CRUD. W Ash każda operacja biznesowa to osobna akcja z własnymi regułami:
actions do
# Domyślne akcje
defaults [:read, :destroy]
# Tworzenie z logiką biznesową
create :create do
accept [:customer_id, :notes]
argument :items, {:array, :map}, allow_nil?: false
change {MyApp.Sales.Changes.GenerateOrderNumber, []}
change manage_relationship(:items, :items, type: :create)
change {MyApp.Sales.Changes.CalculateTotal, []}
end
# Akcja: potwierdź zamówienie
update :confirm do
accept [] # Nie przyjmuje żadnych danych z zewnątrz
# Walidacja: można potwierdzić tylko draft
validate attribute_equals(:status, :draft)
# Walidacja: klient nie może mieć przekroczonego limitu
validate {MyApp.Sales.Validations.CreditLimit, []}
# Zmiana statusu
change set_attribute(:status, :confirmed)
# Side effect: wyślij powiadomienie
change {MyApp.Sales.Changes.NotifyConfirmation, []}
end
# Akcja: anuluj zamówienie
update :cancel do
accept [:cancellation_reason]
# Anulować można draft lub confirmed, ale nie shipped
validate attribute_does_not_equal(:status, :shipped)
validate attribute_does_not_equal(:status, :completed)
change set_attribute(:status, :cancelled)
change {MyApp.Sales.Changes.ReleaseStock, []}
end
# Akcja odczytu: zamówienia z bieżącego miesiąca
read :this_month do
filter expr(inserted_at >= ago(30, :day))
prepare build(sort: [inserted_at: :desc])
end
# Akcja odczytu: zamówienia klienta
read :for_customer do
argument :customer_id, :uuid, allow_nil?: false
filter expr(customer_id == ^arg(:customer_id))
end
endKażda akcja to zamknięty, przetestowany zestaw reguł. Handlowiec wywołuje :create, kierownik :confirm, magazynier :ship. Nikt nie obejdzie reguł, bo nie ma innego sposobu na zmianę danych niż przez zdefiniowane akcje.
Polityki - kto co może
policies do
# Odczyt - wszyscy zalogowani
policy action_type(:read) do
authorize_if always()
end
# Tworzenie - handlowcy i admini
policy action(:create) do
authorize_if actor_attribute_equals(:role, :sales)
authorize_if actor_attribute_equals(:role, :admin)
end
# Potwierdzanie - kierownicy i admini
policy action(:confirm) do
authorize_if actor_attribute_equals(:role, :manager)
authorize_if actor_attribute_equals(:role, :admin)
end
# Anulowanie - tylko autor zamówienia lub admin
policy action(:cancel) do
authorize_if expr(created_by_id == ^actor(:id))
authorize_if actor_attribute_equals(:role, :admin)
end
# Usuwanie - tylko admin
policy action_type(:destroy) do
authorize_if actor_attribute_equals(:role, :admin)
end
endPolityki to nie jest middleware, który możesz obejść. To integralna część zasobu. Każde wywołanie akcji przechodzi przez polityki - z LiveView, z GraphQL, z task'a w konsoli. Jedno miejsce definicji, 100% pokrycia.
Co Ash generuje automatycznie
AshPostgres - baza danych
# Zasób Ash:
attribute :status, :atom do
constraints one_of: [:draft, :confirmed, :paid, :shipped]
default :draft
end
# Ash generuje migrację:
# CREATE TABLE orders (
# id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
# status VARCHAR(255) NOT NULL DEFAULT 'draft'
# CHECK (status IN ('draft', 'confirmed', 'paid', 'shipped')),
# ...
# );Migracje tworzone automatycznie z mix ash.codegen. Constraints z definicji zasobu → constraints w PostgreSQL. Jedno źródło prawdy.
AshJsonApi - REST API
# Dodaj do zasobu:
defmodule MyApp.Sales.Order do
use Ash.Resource,
extensions: [AshJsonApi.Resource]
json_api do
type "order"
routes do
base "/orders"
get :read
index :read
post :create
patch :confirm, route: "/:id/confirm"
patch :ship, route: "/:id/ship"
delete :destroy
end
end
end
# Automatycznie masz:
# GET /api/orders → lista zamówień (z paginacją, filtrowaniem)
# GET /api/orders/:id → szczegóły zamówienia
# POST /api/orders → utwórz zamówienie
# PATCH /api/orders/:id/confirm → potwierdź zamówienie
# PATCH /api/orders/:id/ship → oznacz jako wysłane
# DELETE /api/orders/:id → usuń zamówienie
#
# Wszystko z walidacją, autoryzacją i formatem JSON:API
# Zero controllerów do napisaniaAshGraphql - GraphQL API
# Dodaj do zasobu:
defmodule MyApp.Sales.Order do
use Ash.Resource,
extensions: [AshGraphql.Resource]
graphql do
type :order
queries do
get :get_order, :read
list :list_orders, :read
list :orders_this_month, :this_month
end
mutations do
create :create_order, :create
update :confirm_order, :confirm
update :ship_order, :ship
destroy :delete_order, :destroy
end
end
end
# Automatycznie generuje schemat GraphQL:
# type Order { id: ID!, number: String!, status: OrderStatus!, ... }
# query { getOrder(id: ID!): Order, listOrders(...): [Order] }
# mutation { createOrder(...): Order, confirmOrder(...): Order }
#
# Zero resolverów do napisaniaTen sam zasób, te same reguły, trzy interfejsy - LiveView, REST API, GraphQL. Logika biznesowa napisana raz, dostępna wszędzie.
Porównanie: Ash vs tradycyjny Phoenix
Zmierzmy ilość kodu dla systemu z 5 zasobami (Customer, Order, OrderItem, Product, Invoice):
Tradycyjny Phoenix
Per zasób:
├── schema.ex (changeset) ~50 linii
├── context.ex (CRUD + logika) ~120 linii
├── controller.ex ~80 linii
├── view/json.ex ~30 linii
├── live/index.ex ~60 linii
├── live/show.ex ~50 linii
├── live/form_component.ex ~70 linii
├── authorization (plugi) ~40 linii
└── tests ~150 linii
─────────
~650 linii × 5 = ~3 250 linii
Pliki wspólne:
├── router.ex ~50 linii
├── auth plug ~40 linii
└── error handling ~30 linii
─────────
~120 linii
RAZEM: ~3 370 linii w ~50 plikachAsh Framework
Per zasób:
├── resource.ex (wszystko) ~80 linii
└── tests ~60 linii
─────────
~140 linii × 5 = ~700 linii
Pliki wspólne:
├── domain.ex ~20 linii
├── router.ex ~20 linii
└── repo.ex ~10 linii
─────────
~50 linii
RAZEM: ~750 linii w ~12 plikach| Metryka | Phoenix tradycyjny | Ash Framework |
|---|---|---|
| Linie kodu | ~3 370 | ~750 |
| Pliki | ~50 | ~12 |
| Redukcja kodu | - | 78% |
| Czas implementacji | 3-4 tygodnie | 1-1.5 tygodnia |
78% mniej kodu to nie tylko szybszy development. To mniej miejsc na bugi, mniej kodu do utrzymania, mniej do czytania przy onboardingu nowego programisty.
Kalkulacje i agregacje
Ash pozwala definiować kalkulacje i agregacje deklaratywnie, a optymalizuje je automatycznie (pushdown do SQL):
defmodule MyApp.Sales.Customer do
use Ash.Resource,
domain: MyApp.Sales,
data_layer: AshPostgres.DataLayer
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :credit_limit, :decimal, default: Decimal.new("50000"), public?: true
timestamps()
end
relationships do
has_many :orders, MyApp.Sales.Order
end
aggregates do
count :order_count, :orders
count :active_order_count, :orders do
filter expr(status in [:draft, :confirmed, :paid, :shipped])
end
sum :total_revenue, :orders, :total_price do
filter expr(status == :completed)
end
max :last_order_date, :orders, :inserted_at
end
calculations do
calculate :credit_used, :decimal, expr(
sum(orders, field: :total_price, query: [filter: expr(status in [:confirmed, :paid, :shipped])])
)
calculate :credit_available, :decimal, expr(credit_limit - credit_used)
calculate :is_vip, :boolean, expr(total_revenue > 100_000)
calculate :days_since_last_order, :integer, expr(
fragment("EXTRACT(DAY FROM NOW() - ?)", last_order_date)
)
end
end
# Użycie:
MyApp.Sales.Customer
|> Ash.Query.load([:order_count, :total_revenue, :credit_available, :is_vip])
|> Ash.read!()
# Ash generuje JEDEN zoptymalizowany SQL z JOINami i subqueries
# Nie ma problemu N+1 - bo nie ma pętliWalidacje w Ash
actions do
create :create do
accept [:name, :email, :price, :category]
# Wbudowane walidacje
validate string_length(:name, min: 3, max: 200)
validate match(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
validate numericality(:price, greater_than: 0)
# Walidacja niestandardowa - jako moduł
validate {MyApp.Validations.UniqueInCategory, attribute: :name}
# Walidacja z warunkiem
validate numericality(:price, less_than: 10_000) do
where [attribute_equals(:category, :accessories)]
message "akcesoria nie mogą kosztować więcej niż 10 000 PLN"
end
end
endWalidacje można współdzielić między zasobami - zdefiniuj raz, użyj wszędzie.
Multitenancy - systemy SaaS
Ash ma wbudowane wsparcie dla multitenancy - idealny do systemów SaaS, gdzie wielu klientów dzieli jedną aplikację:
defmodule MyApp.Sales.Order do
use Ash.Resource,
domain: MyApp.Sales,
data_layer: AshPostgres.DataLayer
multitenancy do
strategy :context # Tenant z kontekstu (np. subdomena)
# Alternatywnie:
# strategy :attribute # Tenant jako kolumna w tabeli
# attribute :company_id
end
# Reszta definicji zasobu...
end
# Użycie:
# Każde zapytanie automatycznie filtrowane po tenancie
MyApp.Sales.Order
|> Ash.Query.set_tenant("company-abc")
|> Ash.read!()
# SQL: SELECT * FROM orders WHERE tenant = 'company-abc'
# Niemożliwe jest odczytanie danych innego tenantaAsh vs inne frameworki deklaratywne
| Cecha | Ash (Elixir) | Rails ActiveRecord | Django ORM | Prisma (Node.js) |
|---|---|---|---|---|
| Deklaratywne zasoby | ✓ | Częściowo | Częściowo | Schema only |
| Autoryzacja w zasobie | ✓ (policies) | Pundit (osobna gem) | DRF permissions | Brak |
| Auto-generowanie API | REST + GraphQL | Brak | DRF (ręczne) | Brak |
| Walidacje per akcja | ✓ (natywnie) | Warunkowo | Warunkowo | Brak |
| Kalkulacje SQL | ✓ (pushdown) | Scopes (ręczne) | Annotations (ręczne) | Ręczne |
| Multitenancy | Wbudowany | Gem (apartment) | Ręczne | Ręczne |
| Soft delete | Wbudowany | Gem (paranoia) | Ręczne | Ręczne |
| Agregacje deklaratywne | ✓ | Ręczne | Ręczne | Ręczne |
| Współbieżność (BEAM) | ✓ | ✗ (GIL) | ✗ (GIL) | ✗ (event loop) |
Kiedy Ash, a kiedy czysty Phoenix
| Scenariusz | Ash Framework | Czysty Phoenix |
|---|---|---|
| System z 20+ encjami (ERP, CRM) | ✓ | Dużo boilerplate |
| CRUD-heavy z politykami dostępu | ✓ | Dużo ręcznego kodu |
| SaaS z multitenancy | ✓ | Ręczna implementacja |
| Potrzebujesz REST + GraphQL | ✓ | 2× więcej kodu |
| Prototyp / MVP (5 encji) | Overkill | ✓ |
| Nietypowa logika (gry, symulacje) | Za dużo customizacji | ✓ |
| Integracja z legacy API | Czysty Phoenix lepszy | ✓ |
Ash nie zastępuje Phoenix - rozszerza go. Phoenix obsługuje HTTP, WebSocket, LiveView. Ash obsługuje domenę biznesową: zasoby, akcje, polityki. Razem tworzą kompletny stack.
Co to oznacza dla budżetu
System ERP z 30 encjami, 5 rolami użytkowników, REST API + LiveView:
| Składnik | Tradycyjnie (Phoenix) | Z Ash Framework |
|---|---|---|
| Definicja zasobów + CRUD | 4 tygodnie | 1.5 tygodnia |
| Polityki dostępu | 2 tygodnie | 3 dni (deklaratywne) |
| API (REST + GraphQL) | 3 tygodnie | 3 dni (auto-generowane) |
| Walidacje biznesowe | 2 tygodnie | 1 tydzień |
| Testy | 2 tygodnie | 1 tydzień (mniej kodu) |
| Łącznie | 13 tygodni | 5.5 tygodnia |
Przy stawce zespołu 30 000 PLN/tydzień: 225 000 PLN oszczędności na samym developmencie. Plus niższe koszty utrzymania dzięki 78% mniej kodu.
Chcesz zobaczyć, jak Ash Framework przyspieszy budowę Twojego systemu? Porozmawiajmy - pokażemy prototyp oparty na Twoim modelu biznesowym.