Como escalar aplicações em Elixir e Phoenix: práticas de código e infraestrutura

Escalar uma aplicação em Elixir não é uma questão de apertar um botão. É um conjunto de decisões arquiteturais, de código e de infraestrutura que precisam ser tomadas de forma consistente ao longo do ciclo de vida do produto. A boa notícia é que a BEAM já oferece a fundação — o que você precisa é não desperdiçá-la com anti-patterns no código.

Este guia reúne as práticas mais relevantes — o que evitar, o que implementar e como configurar a infraestrutura — para times que constroem produtos sérios com Elixir e Phoenix.

O que não fazer para escalar (e por quê esses erros são tão comuns)

Antes de falar sobre o que fazer, é mais eficiente endereçar o que desfaz o potencial do Elixir. Esses erros costumam surgir quando desenvolvedores vindos de Rails ou Django trazem hábitos de outras linguagens sem adaptar ao paradigma do Phoenix.

Erro 1: lógica de negócio no controller

Esse é provavelmente o anti-pattern mais comum em aplicações Phoenix. O Phoenix é construído em torno do conceito de contextos — módulos que encapsulam a lógica de negócio de um domínio específico. Quando queries Ecto e regras de negócio vazam para o controller, a modularidade e testabilidade desaparecem.

O padrão errado:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  import Ecto.Query, warn: false
  alias MyApp.Accounts.User
  alias MyApp.Repo
  def index(conn, %{} = params) do
    users = User |> where(name: params["name"]) |> Repo.all()
    render(conn, "index.json", users: users)
  end
end

O padrão correto — o controller apenas filtra parâmetros e delega ao contexto:

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  alias MyApp.Accounts
  action_fallback MyAppWeb.FallbackController
  def index(conn, %{} = params) do
    users = params |> permitted_filter_params() |> Accounts.list_users()
    render(conn, "index.json", users: users)
  end
  defp permitted_filter_params(%{} = params) do
    Map.take(params, [:name, :email, :status])
  end
end

À medida que o contexto cresce, divida-o em submódulos:

MyApp.Accounts
MyApp.Accounts.Configuration
MyApp.Accounts.Billing

Cada submódulo fica em seu próprio arquivo .ex, mantendo hierarquia clara e facilitando testes unitários por escopo.

Erro 2: “Enum hell” em grandes conjuntos de dados

O módulo Enum do Elixir é poderoso — Enum.filter/2 e Enum.map/2 executam incrivelmente rápido mesmo para 10 milhões de entradas. Mas encadear múltiplas iterações sobre grandes conjuntos em memória é um gargalo desnecessário:

# Padrão a evitar
permitted_params
|> Enum.map(&SomeModule.transform/1)
|> Enum.filter(&SomeModule.validate/1)
|> Enum.map(&SomeModule.convert/1)
|> Enum.sort(&SomeModule.compare/1)

A solução é transferir essas operações para o banco de dados via Ecto Query, onde índices e o planner do PostgreSQL fazem o trabalho pesado com muito mais eficiência. Quando precisar iterar em memória, faça isso sobre listas já paginadas — nunca sobre o conjunto completo.

Erro 3: deploy on-premise sem motivo técnico justificado

Infraestrutura on-premise raramente é justificável para aplicações web em estágio de escala. Cloud oferece backups automáticos, scaling configurável, ferramentas consolidadas de deploy e, na maioria dos cenários, segurança superior à que um time interno consegue garantir para uma máquina física dedicada. A complexidade de automatizar deploy em on-premise geralmente não compensa.

Práticas de código que fazem diferença em escala

Aplicações Umbrella para separar domínios

Quando o projeto cresce e começa a misturar domínios distintos, aplicações Umbrella oferecem uma separação inicial organizada sem precisar criar microsserviços imediatamente. Uma Umbrella é um mono-repo com múltiplos projetos Elixir que compartilham dependências mas têm releases separáveis.

mix new my_app --umbrella

A estrutura resultante permite separar o domínio do servidor HTTP:

my_app/
  apps/
    my_app/          # lógica de negócio
    my_app_web/      # controllers, routers, LiveView

Atenção: se os projetos dentro da Umbrella precisarem de versões diferentes das mesmas dependências, isso é um sinal de que você tem serviços genuinamente distintos — e projetos separados fazem mais sentido.

Paginação sempre — sem exceção

Nenhum endpoint de API deve retornar todos os registros de uma tabela de uma vez. Em sistemas com qualquer volume significativo de dados, essa chamada simplesmente não chega ou trava o servidor.

Use bibliotecas como Paginator ou Scrivener para retornar sempre fatias paginadas:

# Com Paginator
def list_users(params) do
  User
  |> order_by([u], u.inserted_at)
  |> Repo.paginate(params)
end

O front-end precisa ser capaz de consumir paginação — uma listagem completa sem tratamento sofre do mesmo problema no lado do cliente.

IDs não sequenciais (UUIDv4)

Usar IDs sequenciais (1, 2, 3…) em entidades expostas via API revela informação operacional sensível: quantidade de usuários, frequência de criação de registros, volume do sistema. Um ID como 2c9695b7-f899-4849-9d20-203a65e44474 não diz nada.

Para habilitar UUIDv4 por padrão em todas as entidades:

# config/config.exs
config :my_app,
  ecto_repos: [MyApp.Repo],
  generators: [binary_id: true]

O schema gerado fica:

@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
  # ...
end

E a migration correspondente:

def change do
  create table(:users, primary_key: false) do
    add :id, :binary_id, primary_key: true
    timestamps()
  end
end

Em bancos distribuídos (multi-shard), UUIDs têm vantagem adicional: permitem inserção em múltiplos nós sem coordenação central, pois cada nó gera seus próprios identificadores únicos sem conflito.

Trafegar menos dados sempre que possível

A compressão gzip é um ganho imediato e trivial de configurar:

# lib/my_app_web/endpoint.ex
plug Plug.Static,
  at: "/",
  from: :my_app_web,
  gzip: true,
  only: ~w(css fonts images js)

# config/prod.exs
config :my_app_web, MyAppWeb.Endpoint,
  http: [port: {:system, "PORT"}, compress: true]

Para reduzir dados trafegados nas respostas de API, GraphQL permite que o cliente especifique exatamente quais campos precisa — eliminando o problema de over-fetching em endpoints REST convencionais. Para comunicação entre serviços internos, gRPC reduz o payload ainda mais via serialização binária.

Delegar cálculos computacionalmente pesados

Conversão de vídeo, geração de relatórios complexos, processamento de imagens em lote — essas tarefas não devem viver na sua aplicação monolítica. O BEAM é excepcional para I/O e concorrência, não para CPU-bound tasks intensivas.

Estratégias práticas:

  • Serviços auxiliares em Node.js ou Python para processamento específico
  • Funções serverless (AWS Lambda, Google Cloud Functions) para tarefas de alta demanda episódica — não importa quantas requisições simultâneas cheguem, uma nova instância é criada automaticamente para cada uma

Tarefas assíncronas: nenhum servidor deve ficar parado esperando

Enviar e-mail de boas-vindas, processar uma integração pós-cadastro, gerar um relatório — nenhuma dessas operações deve bloquear a resposta HTTP. Todo processamento não instantâneo deve ser desacoplado do request-response:

O Elixir oferece opções nativas (GenServer, Task) e bibliotecas maduras como Oban para filas de jobs persistidos no banco, com retry automático, scheduling e monitoramento. Para integrações externas, Exq + Redis é outra alternativa consolidada.

Infraestrutura: o que configurar para aguentar carga

Cache, APM, CDN e WAF

Quatro ferramentas que aparecem em qualquer sistema escalado sério:

Cache (Redis, Memcached): para dados de acesso frequente e custo alto de recomputação. Disponível como serviço em todas as clouds principais.

APM (DataDog, New Relic, Elastic APM): essencial para identificar onde os requests demoram, quais endpoints geram mais erros e onde está o gargalo. Você não consegue otimizar o que não consegue medir.

CDN (Cloudflare, AWS CloudFront): fundamental para servir assets (imagens, CSS, JS, vídeos) com baixa latência globalmente. Instala pontos de presença próximos aos usuários finais.

WAF (Web Application Firewall): protege contra DDoS, bots maliciosos e tentativas de exploração. Disponível como serviço via Cloudflare, AWS WAF, Google Cloud Armor. Um sistema que escala também vira alvo — WAF é a primeira linha de defesa.

Scaling vertical e horizontal com Kubernetes

Scaling vertical aumenta os recursos (CPU, RAM) das máquinas que rodam sua aplicação. Em Kubernetes, isso é configurável por cluster com autoscaling vertical baseado em consumo.

Scaling horizontal aumenta o número de instâncias do seu serviço. O Kubernetes oferece o Horizontal Pod Autoscaler (HPA), que escala o número de pods automaticamente com base em métricas de CPU, memória ou métricas customizadas:

kubectl autoscale deployment my_app \
  --cpu-percent=70 \
  --min=2 \
  --max=10

Tipo

O que escala

Melhor para

Vertical

Poder de processamento de um nó

Picos previsíveis, baixa complexidade operacional

Horizontal

Número de instâncias

Alta disponibilidade, distribuição de carga, zero downtime

Para aplicações Elixir com clustering (múltiplos nós BEAM se comunicando), o scaling horizontal tem uma camada adicional: configurar o libcluster para que os nós se descubram automaticamente em topologias Kubernetes.

Construindo uma API com Phoenix 1.7+: o ponto de partida

Se você está começando um projeto de API REST com Phoenix, o caminho mais rápido é:

# Criar projeto sem HTML, com IDs binários
mix phx.new my_app --no-html --binary-id
# Gerar CRUD JSON
mix phx.gen.json Accounts User users email:string
# Criar banco e rodar migrations
mix ecto.create && mix ecto.migrate

O mix phx.gen.json gera automaticamente controller, schema, views e testes para o CRUD. Com –binary-id, todos os schemas nascem com UUIDv4 por padrão.

Autenticação JWT com Guardian

Para proteger endpoints com tokens JWT:

# mix.exs
{:bcrypt_elixir, "~> 3.0"},
{:guardian, "~> 2.3"}

O Guardian define dois módulos centrais:

# lib/my_app/accounts/guardian.ex
defmodule MyApp.Accounts.Guardian do
  use Guardian, otp_app: :my_app
  alias MyApp.Accounts
  def subject_for_token(user, _claims), do: {:ok, to_string(user.id)}
  def resource_from_claims(%{"sub" => id}) do
    user = Accounts.get_user!(id)
    {:ok, user}
  rescue
    Ecto.NoResultsError -> {:error, :resource_not_found}
  end
end

No router, separe rotas públicas (criação de conta, login) de rotas protegidas com EnsureAuthenticated:

# Rotas públicas
scope "/api", MyAppWeb do
  pipe_through [:api, :auth]
  post "/users", UserController, :create
  post "/login", SessionController, :login
end
# Rotas protegidas
scope "/api", MyAppWeb do
  pipe_through [:api, :auth, :ensure_auth]
  resources "/users", UserController, except: [:new, :edit]
end

Cobertura de testes com ExCoveralls

# mix.exs
{:excoveralls, "~> 0.16", only: :test}

Para gerar relatório HTML de cobertura:

mix coveralls.html

O relatório em cover/excoveralls.html mostra exatamente quais linhas e branches foram exercidos pelos testes — essencial para identificar partes críticas do código sem cobertura.

Escalar é um processo, não um estado

O que faz uma aplicação Elixir escalar não é um checklist fixo — é a capacidade do time de identificar gargalos e atacar o ponto certo no momento certo. Código bem arquitetado (contextos claros, sem Enum hell, sem lógica no controller), infraestrutura bem configurada (paginação, gzip, cache) e observabilidade (APM) formam a base.

Na Ateliware, construímos produtos em Elixir onde escalabilidade e custo de manutenção são requisitos reais — não hipóteses acadêmicas. Se o seu time está enfrentando gargalos em uma aplicação existente ou projetando a arquitetura de um novo produto, o Discovery guiado por IA pode ajudar a identificar os pontos críticos e definir o caminho técnico mais eficiente.

Referências

Posts relacionados