Elixir Архитектура Elixir-приложений: контексты, гексагональная архитектура, Ecto и устойчивость
0%

Архитектура Elixir-приложений: контексты, гексагональная архитектура, Ecto и устойчивость

Архитектура Elixir-приложений

Синтаксис и OTP вы знаете; теперь вопрос — как организовать это в поддерживаемую систему. Elixir поощряет функциональное ядро в устойчивой оболочке: чистая бизнес-логика, обёрнутая в тонкий слой процессов и I/O. Разберём контексты, границы, конфигурацию, работу с БД через Ecto и паттерны надёжности.

Функциональное ядро, императивная оболочка

Базовый принцип, к которому сходится опытный Elixir-код (и книга «Designing Elixir Systems with OTP»):

  • Ядро — чистые функции: принимают данные, возвращают данные, без побочных эффектов. Легко тестировать, легко рассуждать, легко переиспользовать.
  • Оболочка — процессы (GenServer), I/O (БД, сеть), эффекты. Тонкая, «глупая», делегирует решения ядру.

Ошибка новичка — тащить всё в GenServer, делая состояние там, где хватило бы чистой функции. GenServer нужен для конкурентного разделяемого состояния и жизненного цикла, а не для организации кода.

Контексты — модульные границы предметной области

Phoenix и идиоматичный Elixir организуют код по контекстам (bounded contexts из DDD). Контекст — это модуль-фасад, за которым спрятана целая подобласть: её схемы, логика и работа с БД. Внешний код зовёт только публичные функции контекста, не зная о внутренностях.

lib/my_app/
├── accounts/                 # ← контекст Accounts
│   ├── user.ex               # схема (внутренняя)
│   └── token.ex
├── accounts.ex               # ← ПУБЛИЧНЫЙ ФАСАД контекста
├── billing/
│   ├── invoice.ex
│   └── payment.ex
├── billing.ex                # ← публичный фасад
# lib/my_app/accounts.ex — публичный API контекста
defmodule MyApp.Accounts do
  alias MyApp.{Repo, Accounts.User}

  @doc "Регистрирует пользователя. Возвращает {:ok, user} | {:error, changeset}"
  def register_user(attrs) do
    %User{}
    |> User.registration_changeset(attrs)
    |> Repo.insert()
  end

  def get_user!(id), do: Repo.get!(User, id)
  def list_active_users, do: Repo.all(from u in User, where: u.active)
end

Правила границ:

  • Другие части системы работают только через фасад (MyApp.Accounts.register_user/1), не трогая MyApp.Accounts.User напрямую.
  • Схемы БД не покидают свой контекст без нужды — контекст возвращает данные, а не «сырые» Ecto-структуры куда попало.
  • Контексты не зовут друг друга через внутренности — только через публичные фасады. Так снижается связность.

Это даёт слоистость без церемоний: web-слой (контроллеры/LiveView) → контексты (бизнес-логика) → Repo (данные).

Гексагональная архитектура и инверсия зависимостей

Когда нужно изолировать домен от внешнего мира (сменные адаптеры к платёжкам, брокерам, внешним API), применяют порты и адаптеры (гексагональная архитектура). В Elixir «порт» — это behaviour, а «адаптер» — модуль-реализация, выбираемый через конфигурацию.

# Порт: контракт платёжного шлюза
defmodule MyApp.Billing.PaymentGateway do
  @callback charge(amount :: integer(), token :: String.t()) ::
              {:ok, charge_id :: String.t()} | {:error, term()}
end

# Адаптер: Stripe
defmodule MyApp.Billing.StripeGateway do
  @behaviour MyApp.Billing.PaymentGateway
  @impl true
  def charge(amount, token), do: {:ok, "ch_123"}   # ... реальный вызов Stripe
end

# Домен зависит от порта, а конкретный адаптер берёт из конфигурации
defmodule MyApp.Billing do
  defp gateway, do: Application.fetch_env!(:my_app, :payment_gateway)

  def pay(amount, token), do: gateway().charge(amount, token)
end
# config/prod.exs
config :my_app, :payment_gateway, MyApp.Billing.StripeGateway
# config/test.exs
config :my_app, :payment_gateway, MyApp.Billing.PaymentGatewayMock   # Mox

Домен ничего не знает о Stripe — только о контракте. В тестах подставляется мок (см. главу Тестирование). Это и есть DI в Elixir: не контейнеры внедрения, а поведения + конфигурация.

Ecto — работа с базой данных

Ecto — не «ORM», а тулкит из чётко разделённых частей: Repo (шлюз к БД), Schema (отображение таблицы на структуру), Changeset (валидация и подготовка изменений), Query (типобезопасный DSL запросов).

Schema

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string
    field :name, :string
    field :age, :integer
    field :active, :boolean, default: true
    has_many :posts, MyApp.Blog.Post
    timestamps()            # inserted_at / updated_at
  end
end

Changeset — валидация и трансформация

Changeset — сердце Ecto: он не «модель с валидацией», а описание изменения с проверками, приведением типов и списком ошибок. Данные из внешнего мира всегда проходят через changeset.

def registration_changeset(user, attrs) do
  user
  |> cast(attrs, [:email, :name, :age])     # взять только разрешённые поля (защита от mass-assignment)
  |> validate_required([:email, :name])
  |> validate_format(:email, ~r/@/)
  |> validate_number(:age, greater_than_or_equal_to: 0)
  |> unique_constraint(:email)              # опирается на UNIQUE-индекс в БД
end

cast/3 — важная деталь безопасности: он пропускает только явно перечисленные поля, отсекая попытки прислать лишнее (is_admin: true). Валидации накапливают ошибки; Repo.insert(changeset) вернёт {:error, changeset} со всеми проблемами сразу — удобно для форм.

Query — типобезопасные запросы

import Ecto.Query

# Ecto проверяет запрос на этапе компиляции: опечатка в имени поля — ошибка сборки
query =
  from u in User,
    where: u.active == true and u.age >= 18,
    order_by: [desc: u.inserted_at],
    limit: 10,
    preload: [:posts]       # избегаем N+1: подгружаем связанные записи одним планом

Repo.all(query)

Миграции — версионирование схемы БД

mix ecto.gen.migration add_users_table
defmodule MyApp.Repo.Migrations.AddUsersTable do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email, :string, null: false
      add :name, :string
      add :age, :integer
      add :active, :boolean, default: true, null: false
      timestamps()
    end

    create unique_index(:users, [:email])   # тот самый индекс под unique_constraint
  end
end
mix ecto.migrate         # применить
mix ecto.rollback        # откатить последнюю

Миграции — версионируемая история схемы, коммитятся в репозиторий и применяются на деплое (об этом — в главе про деплой). change/0 умеет автоматически откатываться для большинства операций; для сложных пишут отдельные up/0 и down/0.

Транзакции для многошаговых операций — Ecto.Multi, декларативно и с автоматическим откатом при любой ошибке:

alias Ecto.Multi

Multi.new()
|> Multi.insert(:user, user_changeset)
|> Multi.insert(:profile, fn %{user: user} -> profile_changeset(user) end)
|> Repo.transaction()      # либо оба insert, либо ни одного
# => {:ok, %{user: ..., profile: ...}} | {:error, failed_step, changeset, changes_so_far}

Конфигурация приложения

Различие compile-time vs runtime критично для продакшена (повторим из главы про инструментарий):

  • config/config.exs, config/{dev,test,prod}.exscompile-time. Никаких секретов: они «впекутся» в артефакт.
  • config/runtime.exsruntime, читается при старте релиза. Здесь System.fetch_env!/1 — секреты, URL БД, всё средозависимое.
# config/runtime.exs
import Config

if config_env() == :prod do
  config :my_app, MyApp.Repo,
    url: System.fetch_env!("DATABASE_URL"),
    pool_size: String.to_integer(System.get_env("POOL_SIZE", "10")),
    ssl: true

  config :my_app, :payment_gateway, MyApp.Billing.StripeGateway
end

Читайте конфиг через Application.fetch_env!/2 в рантайме, а не кэшируйте в атрибутах модуля на этапе компиляции — иначе значение «застынет» на момент сборки.

Паттерны устойчивости

BEAM даёт супервизию из коробки, но продакшн требует большего:

  • Circuit breaker — при череде сбоев внешнего сервиса временно «размыкать цепь», не долбя мёртвый сервис. Библиотеки: fuse.
  • Retry с backoff — повтор с экспоненциальной задержкой и джиттером (retry). Не повторяйте неидемпотентные операции вслепую.
  • Rate limiting — ограничение частоты (например, hammer) для защиты внешних API и себя.
  • Timeouts везде — у GenServer.call таймаут по умолчанию 5 сек; у HTTP-клиентов и запросов к БД задавайте явные таймауты. Зависший вызов без таймаута — источник каскадных отказов.
  • Bulkhead (переборки) — изолируйте пулы ресурсов (отдельный пул соединений/воркеров на подсистему), чтобы перегрузка одной части не утопила остальные. Естественно ложится на отдельные супервизоры/пулы.
  • Идемпотентность — проектируйте операции так, чтобы повтор был безопасен (ключи идемпотентности для платежей). Это упрощает и retry, и «let it crash».

ETS и кэширование

Для быстрого разделяемого доступа в памяти (кэш, счётчики, справочники) — ETS (Erlang Term Storage): конкурентная таблица в памяти с доступом за ~O(1), без прохода через один процесс-бутылочное-горлышко.

:ets.new(:cache, [:set, :public, :named_table, read_concurrency: true])
:ets.insert(:cache, {"key", "value"})
:ets.lookup(:cache, "key")     # [{"key", "value"}]

Готовые кэши поверх ETS с TTL и вытеснением — Cachex, Nebulex. ETS живёт вне процессной кучи — идеально, когда состояние читают многие, а один GenServer стал бы узким местом.

Фоновые задачи

Для отложенной/фоновой работы с гарантиями (ретраи, персистентность в БД, планирование) стандарт индустрии — Oban: очередь задач на Postgres с наблюдаемостью и надёжной доставкой. Для «просто запустить конкурентно без гарантий» — Task.Supervisor.

Phoenix и LiveView (кратко)

Phoenix — основной веб-фреймворк. Его архитектура сама навязывает здоровые границы: EndpointRouterController/LiveViewконтекстыRepo. Контроллер тонкий: разобрал запрос, позвал контекст, отрендерил ответ.

LiveView — интерактивный UI без ручного JavaScript: состояние живёт на сервере в процессе, изменения по WebSocket пересылают только диффы DOM. Это прямое следствие дешёвых процессов BEAM — процесс на каждое соединение стоит копейки. Для многих продуктов LiveView убирает необходимость в отдельном SPA-фронтенде.

Чек-лист архитектуры

  • Бизнес-логика — чистые функции; процессы только там, где нужно состояние/конкурентность.
  • Код разбит на контексты с публичными фасадами; внутренности не торчат наружу.
  • Внешние зависимости — за поведениями (порты), реализация из конфига (адаптеры).
  • Весь внешний ввод проходит через changesets (cast с белым списком полей).
  • Секреты — только в runtime.exs через переменные окружения.
  • У всех внешних вызовов есть таймауты; критичные пути защищены retry/circuit breaker.
  • Многошаговые операции с БД — через Ecto.Multi (атомарность).

Источники

Что дальше

Деплой и наблюдаемость — релизы, Docker, telemetry и трейсинг.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов