Архитектура Elixir-приложений
Синтаксис и OTP вы знаете; теперь вопрос — как организовать это в поддерживаемую систему. Elixir поощряет функциональное ядро в устойчивой оболочке: чистая бизнес-логика, обёрнутая в тонкий слой процессов и I/O. Разберём контексты, границы, конфигурацию, работу с БД через Ecto и паттерны надёжности.
Функциональное ядро, императивная оболочка
Базовый принцип, к которому сходится опытный Elixir-код (и книга «Designing Elixir Systems with OTP»):
- Ядро — чистые функции: принимают данные, возвращают данные, без побочных эффектов. Легко тестировать, легко рассуждать, легко переиспользовать.
- Оболочка — процессы (GenServer), I/O (БД, сеть), эффекты. Тонкая, «глупая», делегирует решения ядру.
правила, вычисления, валидация] end W --> Core P --> Core Core --> DB
Ошибка новичка — тащить всё в 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}.exs— compile-time. Никаких секретов: они «впекутся» в артефакт.config/runtime.exs— runtime, читается при старте релиза. Здесь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 — основной веб-фреймворк. Его архитектура сама навязывает здоровые границы: Endpoint → Router → Controller/LiveView → контексты → Repo. Контроллер тонкий: разобрал запрос, позвал контекст, отрендерил ответ.
LiveView — интерактивный UI без ручного JavaScript: состояние живёт на сервере в процессе, изменения по WebSocket пересылают только диффы DOM. Это прямое следствие дешёвых процессов BEAM — процесс на каждое соединение стоит копейки. Для многих продуктов LiveView убирает необходимость в отдельном SPA-фронтенде.
Чек-лист архитектуры
- Бизнес-логика — чистые функции; процессы только там, где нужно состояние/конкурентность.
- Код разбит на контексты с публичными фасадами; внутренности не торчат наружу.
- Внешние зависимости — за поведениями (порты), реализация из конфига (адаптеры).
- Весь внешний ввод проходит через changesets (
castс белым списком полей). - Секреты — только в
runtime.exsчерез переменные окружения. - У всех внешних вызовов есть таймауты; критичные пути защищены retry/circuit breaker.
- Многошаговые операции с БД — через
Ecto.Multi(атомарность).
Источники
- Ecto, Phoenix Contexts
- Книга «Designing Elixir Systems with OTP» (PragProg) — функциональное ядро и границы.
- Блог dashbit.co/blog и dockyard.com/blog — паттерны Phoenix/Ecto.
Что дальше
Деплой и наблюдаемость — релизы, Docker, telemetry и трейсинг.