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
end

Z 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
end

Constraints 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
end

Akcje - 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
end

Każ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
end

Polityki 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 napisania

AshGraphql - 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 napisania

Ten 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 plikach

Ash 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
MetrykaPhoenix tradycyjnyAsh Framework
Linie kodu~3 370~750
Pliki~50~12
Redukcja kodu-78%
Czas implementacji3-4 tygodnie1-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ętli

Walidacje 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
end

Walidacje 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 tenanta

Ash vs inne frameworki deklaratywne

CechaAsh (Elixir)Rails ActiveRecordDjango ORMPrisma (Node.js)
Deklaratywne zasobyCzęściowoCzęściowoSchema only
Autoryzacja w zasobie✓ (policies)Pundit (osobna gem)DRF permissionsBrak
Auto-generowanie APIREST + GraphQLBrakDRF (ręczne)Brak
Walidacje per akcja✓ (natywnie)WarunkowoWarunkowoBrak
Kalkulacje SQL✓ (pushdown)Scopes (ręczne)Annotations (ręczne)Ręczne
MultitenancyWbudowanyGem (apartment)RęczneRęczne
Soft deleteWbudowanyGem (paranoia)RęczneRęczne
Agregacje deklaratywneRęczneRęczneRęczne
Współbieżność (BEAM)✗ (GIL)✗ (GIL)✗ (event loop)

Kiedy Ash, a kiedy czysty Phoenix

ScenariuszAsh FrameworkCzysty Phoenix
System z 20+ encjami (ERP, CRM)Dużo boilerplate
CRUD-heavy z politykami dostępuDużo ręcznego kodu
SaaS z multitenancyRęczna implementacja
Potrzebujesz REST + GraphQL2× więcej kodu
Prototyp / MVP (5 encji)Overkill
Nietypowa logika (gry, symulacje)Za dużo customizacji
Integracja z legacy APICzysty 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ładnikTradycyjnie (Phoenix)Z Ash Framework
Definicja zasobów + CRUD4 tygodnie1.5 tygodnia
Polityki dostępu2 tygodnie3 dni (deklaratywne)
API (REST + GraphQL)3 tygodnie3 dni (auto-generowane)
Walidacje biznesowe2 tygodnie1 tydzień
Testy2 tygodnie1 tydzień (mniej kodu)
Łącznie13 tygodni5.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.