Ecto i Changeset - jak Elixir eliminuje błędy w danych, zanim trafią do bazy

Piątek, 16:47. Handlowiec wpisuje zamówienie: ilość 1 000, cena jednostkowa 0,00 PLN. System przyjmuje. W poniedziałek księgowość odkrywa fakturę na 0 złotych, towar już wysłany. Klient „zapomina" zapłacić. Strata: 47 000 PLN.

Jak to możliwe? Bo system walidował tylko „czy pole nie jest puste". Nie sprawdzał, czy dane mają sens biznesowy.

W Elixirze ten problem rozwiązuje Ecto Changeset - mechanizm, który traktuje walidację danych jako pierwszorzędny element architektury, nie jako dodatek.

Czym jest Changeset

Changeset to opis zmiany, zanim zostanie zapisana. Nie modyfikujesz danych bezpośrednio - tworzysz obiekt opisujący, co chcesz zmienić, a system decyduje, czy ta zmiana jest dozwolona.

# Changeset to NIE jest zapis do bazy
# To propozycja zmiany, którą można zaakceptować lub odrzucić

changeset = Order.changeset(%Order{}, %{
  customer_id: 42,
  quantity: 1000,
  unit_price: 0.00
})

# changeset.valid? => false
# changeset.errors => [unit_price: {"musi być większa od 0", []}]

# Dane NIGDY nie trafią do bazy w niepoprawnym stanie

Porównajmy to z typowym podejściem:

// Node.js / Express - typowe podejście
app.post('/orders', async (req, res) => {
  // Walidacja? Może jest, może nie...
  // Każdy developer robi to inaczej
  if (!req.body.customer_id) {
    return res.status(400).json({ error: 'Brak klienta' });
  }

  // A co z ceną 0? A ujemną ilością?
  // A zamówieniem dla zablokowanego klienta?
  // Kto o tym pamięta?

  const order = await db.orders.create(req.body);
  res.json(order);
});
// Java / Spring - walidacja przez annotacje
public class CreateOrderRequest {
    @NotNull
    private Long customerId;

    @Min(1)
    private Integer quantity;

    @DecimalMin("0.01")
    private BigDecimal unitPrice;

    // Proste reguły? Tak.
    // "Cena nie może być niższa niż koszt zakupu"? Annotacja tego nie wyrazi.
    // "Klient nie może mieć więcej niż 5 niezapłaconych zamówień"? Też nie.
}

Ecto Changeset łączy prostą walidację pól z dowolnie złożoną logiką biznesową w jednym, spójnym mechanizmie.

Anatomia Changeseta

Schema - definicja struktury danych

defmodule MyApp.Orders.Order do
  use Ecto.Schema

  schema "orders" do
    field :number, :string
    field :quantity, :integer
    field :unit_price, :decimal
    field :total_price, :decimal
    field :status, Ecto.Enum, values: [:draft, :confirmed, :shipped, :completed, :cancelled]
    field :notes, :string
    field :shipping_date, :date

    belongs_to :customer, MyApp.Customers.Customer
    has_many :items, MyApp.Orders.Item

    timestamps()
  end
end

To nie jest ORM w tradycyjnym sensie. Ecto Schema to mapowanie danych, nie obiekt z zachowaniami. Dane i logika są rozdzielone - changeset to logika, schema to struktura.

Changeset - pipeline walidacji

def changeset(order, attrs) do
  order
  |> cast(attrs, [:customer_id, :quantity, :unit_price, :notes, :shipping_date])
  |> validate_required([:customer_id, :quantity, :unit_price])
  |> validate_number(:quantity, greater_than: 0, less_than: 100_000)
  |> validate_number(:unit_price, greater_than: Decimal.new("0"))
  |> validate_length(:notes, max: 1000)
  |> foreign_key_constraint(:customer_id)
  |> generate_order_number()
  |> calculate_total_price()
end

Każda linia to jedno przekształcenie. Pipeline czyta się od góry do dołu jak listę reguł biznesowych. Jeśli którakolwiek walidacja się nie powiedzie, changeset jest oznaczony jako valid?: false i żadna operacja bazodanowa nie zostanie wykonana.

cast - pierwsza linia obrony

cast/3 to strażnik, który decyduje, które pola w ogóle mogą być zmienione:

# Tylko te pola mogą przyjść z formularza / API
cast(attrs, [:customer_id, :quantity, :unit_price, :notes, :shipping_date])

# Co się stanie, jeśli ktoś wyśle:
# %{"quantity" => 10, "status" => "completed", "total_price" => 0}
#
# cast przepuści TYLKO :quantity
# :status i :total_price zostaną zignorowane
# Bo nie ma ich na liście dozwolonych pól

To jest mass assignment protection wbudowany w język. W Rails był z tym słynny problem (GitHub hack 2012). W Phoenix/Ecto to domyślne zachowanie - musisz jawnie zadeklarować, co wolno zmieniać.

Różne changesety dla różnych operacji

defmodule MyApp.Orders.Order do
  # Tworzenie - handlowiec wypełnia formularz
  def create_changeset(order, attrs) do
    order
    |> cast(attrs, [:customer_id, :quantity, :unit_price, :notes])
    |> validate_required([:customer_id, :quantity, :unit_price])
    |> validate_number(:quantity, greater_than: 0)
    |> validate_number(:unit_price, greater_than: Decimal.new("0"))
    |> put_change(:status, :draft)
    |> generate_order_number()
    |> calculate_total_price()
  end

  # Potwierdzenie - kierownik zatwierdza
  def confirm_changeset(order) do
    order
    |> change()
    |> validate_status_transition(:draft, :confirmed)
    |> validate_customer_credit_limit()
    |> put_change(:status, :confirmed)
  end

  # Wysyłka - magazynier oznacza jako wysłane
  def ship_changeset(order, attrs) do
    order
    |> cast(attrs, [:shipping_date])
    |> validate_required([:shipping_date])
    |> validate_status_transition(:confirmed, :shipped)
    |> validate_shipping_date_not_in_past()
    |> put_change(:status, :shipped)
  end

  # Anulowanie - ale tylko jeśli nie wysłano
  def cancel_changeset(order, attrs) do
    order
    |> cast(attrs, [:cancellation_reason])
    |> validate_required([:cancellation_reason])
    |> validate_inclusion(:status, [:draft, :confirmed])
    |> put_change(:status, :cancelled)
  end
end

Każda operacja biznesowa ma własny changeset. Handlowiec nie może zmienić statusu na „wysłane". Magazynier nie może zmienić ceny. Nikt nie może anulować wysłanego zamówienia. Reguły są w kodzie, nie w głowie programisty.

Walidacje wbudowane

Ecto daje zestaw walidacji pokrywający 80% potrzeb:

def changeset(product, attrs) do
  product
  |> cast(attrs, [:name, :sku, :price, :weight, :category, :email, :url])

  # Wymagane pola
  |> validate_required([:name, :sku, :price])

  # Długość tekstu
  |> validate_length(:name, min: 3, max: 200)
  |> validate_length(:sku, is: 10)  # dokładnie 10 znaków

  # Wartości numeryczne
  |> validate_number(:price, greater_than: 0, less_than: 1_000_000)
  |> validate_number(:weight, greater_than_or_equal_to: 0)

  # Lista dozwolonych wartości
  |> validate_inclusion(:category, ["electronics", "furniture", "food"])

  # Lista zabronionych wartości
  |> validate_exclusion(:name, ["test", "TODO", "asdf"])

  # Format (regex)
  |> validate_format(:sku, ~r/^[A-Z]{3}-\d{7}$/)
  |> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)

  # Akceptacja (checkboxy regulaminów)
  |> validate_acceptance(:terms_of_service)

  # Unikalność (sprawdzana przy zapisie do bazy)
  |> unique_constraint(:sku)
  |> unique_constraint([:name, :category], name: :products_name_category_index)

  # Klucze obce
  |> foreign_key_constraint(:category_id)

  # Ograniczenia CHECK z PostgreSQL
  |> check_constraint(:price, name: :price_must_be_positive)
end

Walidacje niestandardowe - logika biznesowa

Prawdziwa moc changesetów to walidacje, których nie wyrazi żadna annotacja ani decorator:

Walidacja limitu kredytowego klienta

defp validate_customer_credit_limit(changeset) do
  validate_change(changeset, :customer_id, fn :customer_id, customer_id ->
    customer = Customers.get_customer!(customer_id)
    unpaid = Orders.total_unpaid_for_customer(customer_id)
    order_value = get_field(changeset, :total_price)

    cond do
      customer.blocked ->
        [customer_id: "klient jest zablokowany"]

      Decimal.add(unpaid, order_value) |> Decimal.gt?(customer.credit_limit) ->
        [customer_id: "przekroczony limit kredytowy (#{customer.credit_limit} PLN)"]

      true ->
        []
    end
  end)
end

Walidacja przejść statusów

# Dozwolone przejścia statusów - automat stanowy
@transitions %{
  draft:     [:confirmed, :cancelled],
  confirmed: [:shipped, :cancelled],
  shipped:   [:completed],
  completed: [],
  cancelled: []
}

defp validate_status_transition(changeset, from, to) do
  allowed = Map.get(@transitions, from, [])

  if to in allowed do
    changeset
  else
    add_error(changeset, :status,
      "nie można zmienić statusu z #{from} na #{to}")
  end
end

# draft → confirmed ✓
# draft → shipped ✗ ("nie można zmienić statusu z draft na shipped")
# completed → draft ✗ ("nie można zmienić statusu z completed na draft")

Walidacja spójności dat

defp validate_shipping_date_not_in_past(changeset) do
  validate_change(changeset, :shipping_date, fn :shipping_date, date ->
    if Date.compare(date, Date.utc_today()) == :lt do
      [shipping_date: "data wysyłki nie może być w przeszłości"]
    else
      []
    end
  end)
end

defp validate_date_range(changeset) do
  start_date = get_field(changeset, :start_date)
  end_date = get_field(changeset, :end_date)

  if start_date && end_date && Date.compare(start_date, end_date) == :gt do
    add_error(changeset, :end_date, "musi być późniejsza niż data początkowa")
  else
    changeset
  end
end

Walidacja zależna od innych pól

def changeset(invoice, attrs) do
  invoice
  |> cast(attrs, [:type, :vat_rate, :vat_id, :amount])
  |> validate_required([:type, :amount])
  |> validate_vat_fields()
end

defp validate_vat_fields(changeset) do
  case get_field(changeset, :type) do
    :domestic ->
      changeset
      |> validate_required([:vat_rate])
      |> validate_inclusion(:vat_rate, [23, 8, 5, 0])

    :eu_export ->
      changeset
      |> validate_required([:vat_id])
      |> validate_format(:vat_id, ~r/^[A-Z]{2}\d{8,12}$/)
      |> put_change(:vat_rate, 0)

    :non_eu_export ->
      changeset
      |> put_change(:vat_rate, 0)

    _ ->
      add_error(changeset, :type, "nieznany typ faktury")
  end
end

Ecto.Multi - transakcje jako pipeline

Changeset waliduje jedną encję. Ale operacje biznesowe często dotyczą wielu tabel jednocześnie. Do tego służy Ecto.Multi:

def confirm_order(order) do
  Ecto.Multi.new()
  |> Ecto.Multi.update(:order, Order.confirm_changeset(order))
  |> Ecto.Multi.run(:stock, fn _repo, %{order: order} ->
    reduce_stock(order)
  end)
  |> Ecto.Multi.insert(:event, fn %{order: order} ->
    OrderEvent.changeset(%OrderEvent{}, %{
      order_id: order.id,
      type: :confirmed,
      performed_by: order.confirmed_by
    })
  end)
  |> Ecto.Multi.run(:notification, fn _repo, %{order: order} ->
    send_confirmation_email(order)
  end)
  |> Repo.transaction()
end

# Co się dzieje:
# 1. Zmień status zamówienia → jeśli changeset invalid → STOP
# 2. Zmniejsz stan magazynowy → jeśli brak towaru → ROLLBACK (1)
# 3. Zapisz event w historii → jeśli błąd → ROLLBACK (1, 2)
# 4. Wyślij email → jeśli błąd → ROLLBACK (1, 2, 3)
#
# Albo WSZYSTKO się udaje, albo NIC się nie zmienia.
# Baza nigdy nie jest w niespójnym stanie.

Porównajmy z typowym kodem bez Multi:

// Node.js - ręczne zarządzanie transakcją
async function confirmOrder(orderId) {
  const trx = await db.transaction();
  try {
    await trx('orders').where({ id: orderId }).update({ status: 'confirmed' });
    await reduceStock(trx, orderId);  // A co jeśli to rzuci wyjątek?
    await trx('order_events').insert({ order_id: orderId, type: 'confirmed' });
    await sendEmail(orderId);         // A co jeśli email padnie?
    await trx.commit();
  } catch (err) {
    await trx.rollback();             // Czy na pewno pamiętamy o rollback?
    throw err;                        // A co jeśli rollback padnie?
  }
}

// Kto waliduje status transition? Kto sprawdza credit limit?
// Kto pilnuje, żeby walidacja była taka sama w API i w UI?
// Odpowiedź: nikt - chyba że ktoś o tym pamięta.

Obsługa błędów w formularzu

Changeset integruje się z Phoenix LiveView tak, że błędy wyświetlają się automatycznie przy właściwych polach:

# LiveView - formularz z walidacją w czasie rzeczywistym
defmodule MyAppWeb.OrderLive.FormComponent do
  use MyAppWeb, :live_component

  def update(%{order: order} = assigns, socket) do
    changeset = Orders.change_order(order)
    {:ok, assign(socket, assigns) |> assign_form(changeset)}
  end

  # Walidacja uruchamia się przy KAŻDEJ zmianie pola
  def handle_event("validate", %{"order" => params}, socket) do
    changeset =
      socket.assigns.order
      |> Orders.change_order(params)
      |> Map.put(:action, :validate)

    {:noreply, assign_form(socket, changeset)}
  end

  # Zapis uruchamia pełną walidację + zapis do bazy
  def handle_event("save", %{"order" => params}, socket) do
    case Orders.create_order(params) do
      {:ok, order} ->
        {:noreply,
         socket
         |> put_flash(:info, "Zamówienie #{order.number} utworzone")
         |> push_navigate(to: ~p"/orders/#{order}")}

      {:error, changeset} ->
        # Błędy automatycznie wyświetlą się przy polach
        {:noreply, assign_form(socket, changeset)}
    end
  end
end
<!-- Template - błędy wyświetlają się automatycznie -->
<.form for={@form} phx-change="validate" phx-submit="save">
  <.input field={@form[:customer_id]} label="Klient" type="select" options={@customers} />
  <.input field={@form[:quantity]} label="Ilość" type="number" />
  <.input field={@form[:unit_price]} label="Cena jednostkowa" type="number" step="0.01" />
  <.input field={@form[:notes]} label="Uwagi" type="textarea" />

  <!-- Jeśli quantity = 0, użytkownik widzi "musi być większa od 0"
       Jeśli unit_price puste, widzi "pole wymagane"
       Jeśli przekroczony credit limit, widzi komunikat
       Wszystko BEZ przeładowania strony, BEZ JavaScriptu -->

  <.button>Utwórz zamówienie</.button>
</.form>

Walidacja działa identycznie w formularzu i w API. Nie ma możliwości, żeby API przyjęło dane, które formularz odrzuciłby. Jeden changeset, jedno źródło prawdy.

Changeset vs inne podejścia

CechaEcto ChangesetJava Bean ValidationExpress + JoiDjango Forms
Walidacja na poziomie pola
Walidacja cross-field✓ (natywnie)Custom validator (klasa)Custom (funkcja)clean() (metoda)
Walidacja z zapytaniem do bazy✓ (validate_change)Wymaga serwisuWymaga middlewareWymaga override
Mass assignment protectionWbudowany (cast)Ręczny (DTO)RęcznyRęczny (fields)
Różne reguły per operacjaOsobne changesetyGrupy walidacyjneOsobne schemasOsobne formy
Pipeline/kompozycjaNatywny (|>)BrakBrakBrak
Integracja z formularzemAutomatyczna (LiveView)RęcznaRęcznaAutomatyczna
Integracja z transakcjamiEcto.Multi@TransactionalRęcznaatomic()
Komunikaty błędów i18nWbudowane (Gettext)messages.propertiesRęczneWbudowane

Wzorce, które stosujemy w produkcji

Wzorzec 1: Walidacja na dwóch poziomach

defmodule MyApp.Orders.Order do
  # Poziom 1: Changeset - reguły aplikacyjne
  def changeset(order, attrs) do
    order
    |> cast(attrs, [:quantity, :unit_price, :customer_id])
    |> validate_required([:quantity, :unit_price, :customer_id])
    |> validate_number(:quantity, greater_than: 0)
    |> validate_number(:unit_price, greater_than: Decimal.new("0"))
    |> foreign_key_constraint(:customer_id)
    |> check_constraint(:total_price, name: :total_must_be_positive)
  end
end
-- Poziom 2: PostgreSQL - reguły bazodanowe (ostatnia linia obrony)
ALTER TABLE orders
  ADD CONSTRAINT total_must_be_positive CHECK (total_price > 0);

ALTER TABLE orders
  ADD CONSTRAINT quantity_must_be_positive CHECK (quantity > 0);

-- Nawet jeśli bug w kodzie ominie changeset,
-- baza danych ODRZUCI niepoprawne dane

Dwie warstwy obrony. Changeset łapie 99% problemów z czytelnymi komunikatami. PostgreSQL CHECK łapie resztę - na wypadek, gdyby ktoś wstawił dane z konsoli lub z innego systemu.

Wzorzec 2: Changeset z kontekstem biznesowym

defmodule MyApp.Orders do
  # Kontekst biznesowy decyduje o regułach
  def create_order(attrs, %User{role: :sales}) do
    %Order{}
    |> Order.create_changeset(attrs)
    |> validate_sales_limits(attrs)
    |> Repo.insert()
  end

  def create_order(attrs, %User{role: :admin}) do
    %Order{}
    |> Order.admin_changeset(attrs)  # Admin może więcej
    |> Repo.insert()
  end

  defp validate_sales_limits(changeset, _attrs) do
    quantity = get_field(changeset, :quantity)
    unit_price = get_field(changeset, :unit_price)

    changeset
    |> validate_number(:quantity, less_than_or_equal_to: 10_000)
    |> validate_discount_limit(unit_price)
  end
end

# Handlowiec: max 10 000 szt., max 15% rabatu
# Admin: bez limitów
# Ta sama baza, te same tabele, różne reguły

Wzorzec 3: Audit trail z changesetem

def update_order(order, attrs, current_user) do
  changeset = Order.update_changeset(order, attrs)

  Ecto.Multi.new()
  |> Ecto.Multi.update(:order, changeset)
  |> Ecto.Multi.insert(:audit, fn %{order: updated_order} ->
    AuditLog.changeset(%AuditLog{}, %{
      entity: "orders",
      entity_id: order.id,
      action: "update",
      user_id: current_user.id,
      changes: changeset.changes |> Map.delete(:updated_at) |> Jason.encode!(),
      previous_values: extract_previous(order, changeset.changes)
    })
  end)
  |> Repo.transaction()
end

# Każda zmiana jest logowana z:
# - Kto zmienił
# - Co zmienił (dokładne pola)
# - Jaka była poprzednia wartość
# - Kiedy zmienił
# Changeset.changes daje DOKŁADNĄ listę zmienionych pól

Wzorzec 4: Walidacja zależna od zewnętrznego systemu

defp validate_vat_number(changeset) do
  validate_change(changeset, :vat_id, fn :vat_id, vat_id ->
    case VIESClient.verify(vat_id) do
      {:ok, %{valid: true}} ->
        []

      {:ok, %{valid: false}} ->
        [vat_id: "numer VAT nie jest aktywny w systemie VIES"]

      {:error, :timeout} ->
        # VIES nie odpowiada - przepuszczamy, zweryfikujemy później
        Logger.warning("VIES timeout for #{vat_id}")
        []
    end
  end)
end

# Walidacja NIP przez VIES:
# - Jeśli numer jest aktywny → przepuść
# - Jeśli nieaktywny → odrzuć z komunikatem
# - Jeśli serwis nie odpowiada → przepuść, zaloguj, sprawdź później

Schemat ochrony danych

Dane od użytkownika (formularz / API / import)


┌─────────────────────────┐
│ cast()                  │ ← Odrzuć nieznane pola
│ Przepuść TYLKO dozwolone│   (mass assignment protection)
└───────────┬─────────────┘


┌─────────────────────────┐
│ validate_required()     │ ← Wymagane pola
│ validate_format()       │ ← Poprawny format
│ validate_number()       │ ← Zakresy wartości
│ validate_length()       │ ← Limity długości
└───────────┬─────────────┘


┌─────────────────────────┐
│ Walidacje biznesowe     │ ← Credit limit, status transition,
│ validate_change()       │   cross-field, zależności
│ add_error()             │
└───────────┬─────────────┘


┌─────────────────────────┐
│ Ecto.Multi              │ ← Transakcja: wszystko albo nic
│ (opcjonalnie)           │   Wiele tabel, spójność
└───────────┬─────────────┘


┌─────────────────────────┐
│ PostgreSQL              │ ← Ostatnia linia obrony
│ CHECK, UNIQUE, FK       │   Constraints bazodanowe
│ NOT NULL                │
└───────────┬─────────────┘


      ✅ Dane w bazie
      (poprawne, spójne, audytowalne)

Ile błędów łapie changeset

Na podstawie naszych projektów - statystyki z systemu e-commerce (6 miesięcy, 340 000 operacji):

WarstwaBłędy złapanePrzykłady
cast (nieznane pola)12 400Próby zmiany statusu, ceny po rabacie, pól systemowych
validate_required8 200Puste formularze, brakujące pola w API
validate_number / format4 100Ujemne ilości, niepoprawne NIP-y, za długie teksty
Walidacje biznesowe2 800Przekroczony credit limit, niedozwolona zmiana statusu
PostgreSQL constraints340Race conditions (unique), usunięty klient (FK)
Łącznie odrzuconych27 8408.2% wszystkich operacji

Prawie co dziesiąta operacja zawierała dane, które nie powinny trafić do bazy. Bez changesetów te dane byłyby zapisane - i powodowałyby problemy tygodniami, zanim ktoś by je zauważył.

Co to oznacza dla Twojego biznesu

Bez systemu walidacji:

  • Handlowiec wpisze cenę 0 PLN → faktura na 0 zł
  • Import z Excela doda duplikaty → zawyżone stany magazynowe
  • API partnera wyśle ujemną ilość → magazyn „przybywa" z nikąd
  • Ktoś zmieni status zamówienia na „wysłane" bez wysyłki → chaos w logistyce
  • Odkrycie błędnych danych → tygodnie lub miesiące po fakcie

Z Ecto Changeset:

  • Błędne dane są odrzucane natychmiast z czytelnym komunikatem
  • Każda operacja ma własne reguły - handlowiec nie obejdzie limitu
  • Transakcje gwarantują spójność między tabelami
  • PostgreSQL jako ostatnia linia obrony łapie race conditions
  • Audit trail loguje kto, co i kiedy zmienił

To nie jest „feature Elixira". To architektura systemu, w którym niepoprawne dane nie mają prawa istnieć.


Chcesz system, w którym dane są zawsze poprawne, a błędy łapane zanim cokolwiek zapiszą? Porozmawiajmy - pokażemy, jak Ecto Changeset chroni dane w prawdziwych systemach biznesowych.