Aprendendo Elixir - Estrutura Modular com Umbrella
Neste segundo post da série Aprendendo Elixir, vamos explorar como organizar projetos maiores usando arquitetura modular com uma estrutura de guarda-chuva (chamado Umbrella pelo Elixir). A ideia é dividir o sistema em múltiplas aplicações menores e coesas, que podem ser desenvolvidas, testadas e integradas dentro de uma aplicação principal. Esse modelo segue o princípio de separação de responsabilidades, facilitando a manutenção e o crescimento do código.
Os exemplos desse post estão em 02-umbrella, e os demais estarão organizados no repositório principal do projeto.
Nosso projeto de exemplo é um conversor unificado com três apps:
conversor_temperatura
– conversão entre Celsius e Fahrenheitconversor_distancia
– conversão entre metros e pésmain
– aplicação principal que orquestra as conversões com interação do usuário
Por que usar um projeto Guarda-Chuva?
Projetos Guarda-Chuva são ideais quando queremos:
- Modularizar funcionalidades de forma clara e reaproveitável
- Separar domínios de forma independente (ex: API, banco, workers)
- Facilitar testes e manutenção em projetos maiores
- Gerenciar dependências locais entre apps sem necessidade de publicar pacotes
Cada app tem seu próprio ciclo de vida e testes, mas todos podem ser carregados e orquestrados a partir do projeto raiz. O Elixir lida muito bem com esse modelo graças à sua estrutura de aplicações OTP, onde cada subprojeto pode ter seu ciclo de vida próprio.
Criando o projeto guarda-chuva
Crie o projeto principal com:
mix new conversor --umbrella
cd conversor
O comando --umbrella
cria uma estrutura inicial com a pasta apps/
onde viverão as subaplicações.
⚠️ Aviso: A partir daqui, sempre que falarmos da raiz do projeto, estamos considerando esta pasta inicial criada com o comando acima.
Agora dentro do diretório apps
, criamos os três subprojetos:
cd apps
mix new conversor_distancia
mix new conversor_temperatura
mix new main
Estrutura do projeto
A estrutura do projeto após esses comandos será:
conversor
├── apps
│ ├── conversor_distancia
│ │ ├── lib
│ │ │ └── conversor_distancia.ex
│ │ └── test
│ │ └── conversor_distancia_test.exs
│ ├── conversor_temperatura
│ │ ├── lib
│ │ │ └── conversor_temperatura.ex
│ │ └── test
│ │ └── conversor_temperatura_test.exs
│ └── main
│ ├── lib
│ │ └── main.ex
│ ├── mix.exs
│ └── test
│ └── main_test.exs
└── mix.exs
⚠️ Aviso: Esta não é a estrutura completa gerada pelos comandos, mas somente os arquivos que iremos nos preocupar neste momento.
O arquivo mix.exs
da raiz define que o projeto é do tipo umbrella por conta da opção apps_path: "apps"
, fazendo a centralização da orquestração do projeto.
Implementando os apps
Conversor.Distancia
Este app de exemplo lida com conversões entre metros e pés.
apps/conversor_distancia/lib/conversor_distancia.ex:
defmodule Conversor.Distancia do
@moduledoc """
Conversão entre metros e pés.
"""
@doc """
Converte metros para pés.
iex> Conversor.Distancia.m_para_ft(1) |> Float.round(4)
3.2808
"""
def m_para_ft(m) when is_number(m), do: m * 3.28084
@doc """
Converte pés para metros.
iex> Conversor.Distancia.ft_para_m(1) |> Float.round(4)
0.3048
"""
def ft_para_m(ft) when is_number(ft), do: ft / 3.28084
end
Conversor.Temperatura
Este app de exemplo trata de conversão entre escalas de temperatura:
apps/conversor_temperatura/lib/conversor_temperatura.ex:
defmodule Conversor.Temperatura do
@moduledoc """
Conversão entre Celsius e Fahrenheit.
"""
@doc """
Converte Celsius para Fahrenheit.
iex> Conversor.Temperatura.c_para_f(0)
32.0
"""
def c_para_f(c) when is_number(c), do: (c * 1.8) + 32
@doc """
Converte Fahrenheit para Celsius.
iex> Conversor.Temperatura.f_para_c(212)
100.0
"""
def f_para_c(f) when is_number(f), do: (f - 32) / 1.8
end
💡 Uma coisa interessante sobre módulos no Elixir é que eles costumam usar nomes com namespaces para formar uma estrutura mais organizada, como
Conversor.Temperatura
. Isso ajuda a deixar o código mais legível e bem dividido, além de permitir uma organização estrutural, mostrando claramente a que parte do sistema cada módulo pertence. É uma convenção comum na linguagem, que facilita entender a função de cada módulo só pelo nome, além de evitar confusão com outros módulos parecidos.
App integrador: Main
Para usar nossos apps, iremos implementar um app principal para chamarmos direto do terminal. Pela simplicidade, vamos implementar uma simples aplicação de terminal com captura de input.
Não é para ser algo bonito, é só para mostrar como podemos rodar nosso projeto do terminal, mas serve facilmente para entender como funciona o ponto de entrada para nossa aplicação.
No mix.exs
do main
, declaramos dependências para os outros dois apps:
apps/main/mix.exs:
defp deps do
[
{:conversor_temperatura, in_umbrella: true},
{:conversor_distancia, in_umbrella: true}
]
end
💡 Em projetos do tipo umbrella, cada app é isolado em sua própria pasta, mas todos compartilham o mesmo ambiente de execução, o que permite que módulos definidos em um app sejam utilizados em outro. Para isso, basta declarar a dependência no
mix.exs
do app que irá consumir (com{:nome_do_app, in_umbrella: true}
), como fizemos emmain
, e os módulos ficam disponíveis automaticamente para uso, sem necessidade de configuração adicional. Isso torna a comunicação entre os apps simples e direta, mantendo a modularização do projeto.
O código interativo:
apps/main/lib/main.ex:
defmodule Main do
def main do
IO.puts("Digite uma temperatura em Celsius:")
celsius = get_float_input()
fahrenheit = Conversor.Temperatura.c_para_f(celsius)
IO.puts("Em Fahrenheit: #{fahrenheit}")
IO.puts("Digite uma distância em metros:")
metros = get_float_input()
ft = Conversor.Distancia.m_para_ft(metros)
IO.puts("Em pés: #{Float.round(ft, 4)}")
end
defp get_float_input do
input = IO.gets("> ") |> String.trim()
case Float.parse(input) do
{valor, ""} -> valor
_ ->
IO.puts("Entrada inválida, tente novamente.")
get_float_input()
end
end
end
⚠️ Aviso: Em projetos futuros não iremos usar esse modo de rodar o projeto, ele serve só de exemplo para podermos interagir com o projeto de forma standalone.
Compilação e execução standalone
Para compilar todo o projeto não têm muito segredo:
mix compile
Isso irá criar uma pasta _build
na raiz do nosso projeto. Essa pasta é sempre gerada automaticamente, então pode ser descartada do gerenciador de versões em uso.
Para rodar nosso projeto de forma standalone, temos três formas diferentes de rodar. Vamos passar por cada uma abaixo:
Usando mix run
Não têm muito o que falar, só mostrar:
mix run -e 'Main.main'
⚠️ Aviso: Apesar de existir a opção de fazermos a execução usando
mix run
sem parâmetros, isso pode causar problemas inesperados com outras partes da execução do projeto (como testes, compilação ou Servers), pois omix run
por padrão executa um script, ou, quando você define uma application commod: {Main, []}
, em que ele chamastart/2
e se a funçãostart/2
não estiver bem configurada, podemos gerar problemas de difícil depuração, por isso não iremos abordar esse método aqui. Mas se quiser verificar, você pode checar na documentação do start/2 no módulo Application, na documentação do Elixir.
Usando Aliases
Para fazermos a execução usando alias no comando mix
, vamos criar um novo alias no projeto. Para isso, configure o mix.exs
da raiz:
mix.exs:
defp aliases do
[
conversor_main: "run -e 'Main.main'"
]
end
Será necessário colocar também a linha aliases: aliases()
no project
do mix.exs
da raiz. Feito isso, é só rodar o comando que o código interativo de Main será executado.
mix conversor_main
Esse comando é o equivalente a rodarmos, no terminal:
mix run -e 'Main.main'
Usando Mix.Tasks
Aqui vamos criar um novo arquivo em apps/main/lib/mix/tasks/conversor_task.ex
, que também cria um alias no mix, com o seguinte conteúdo:
apps/main/lib/mix/tasks/conversor_task.ex:
defmodule Mix.Tasks.ConversorTask do
use Mix.Task
@shortdoc "Executa a aplicação interativa do conversor"
def run(_args) do
# Garante que os apps do projeto estão carregados
Mix.Task.run("app.start")
# Chama a função interativa
Main.main()
end
end
Criada a task, conseguiremos rodar nossa aplicação com a task nova criada.
mix conversor_task
💡 Para simplificar um pouco, usamos uma nomenclatura que nos permite ver quais os comandos que criamos, caso haja dúvidas. Ao rodar
mix help | grep conversor
iremos ver só os comandos que nós criamos.
Usando IEx com projetos umbrella
Com o projeto estruturado, você pode iniciar um console interativo a partir da raiz do projeto umbrella com:
iex -S mix
Isso compila e carrega todas as aplicações definidas em apps/
. Feito isso, você pode interagir com qualquer módulo dentro do projeto:
iex> Conversor.Temperatura.f_para_c(212)
100.0
iex> Conversor.Distancia.m_para_ft(10)
32.8084
Testes
Vamos adicionar testes automatizados para os nossos módulos. Cada módulo possui seus testes, organizados em arquivos separados com ExUnit
. Usaremos valores aleatórios gerados pela seed do ExUnit, garantindo reprodutibilidade com o comando mix test --seed <valor>
. Isso é útil para capturar inconsistências sutis em cálculos com ponto flutuante.
Testes para Conversor.Distancia
apps/conversor_distancia/test/conversor_distancia_test.exs:
defmodule Conversor.DistanciaTest do
use ExUnit.Case, async: true
doctest Conversor.Distancia
describe "Teste do Conversor de Distâncias" do
test "m_para_ft/1 com inteiro aleatório" do
valor = :rand.uniform(100)
esperado = valor * 3.28084
assert_in_delta Conversor.Distancia.m_para_ft(valor), esperado, 0.0001
end
test "ft_para_m/1 com inteiro aleatório" do
valor = :rand.uniform(100)
esperado = valor / 3.28084
assert_in_delta Conversor.Distancia.ft_para_m(valor), esperado, 0.0001
end
end
end
Testes para Conversor.Temperatura
apps/conversor_temperatura/test/conversor_temperatura_test.exs:
defmodule Conversor.TemperaturaTest do
use ExUnit.Case, async: true
doctest Conversor.Temperatura
describe "Teste do Conversor de Temperaturas" do
test "c_para_f/1 com inteiro aleatório" do
valor = :rand.uniform(100) - 50
esperado = (valor * 1.8) + 32
assert_in_delta Conversor.Temperatura.c_para_f(valor), esperado, 0.0001
end
test "f_para_c/1 com inteiro aleatório" do
valor = :rand.uniform(212)
esperado = (valor - 32) / 1.8
assert_in_delta Conversor.Temperatura.f_para_c(valor), esperado, 0.0001
end
end
end
Teste para a chamada interativa de Main
apps/main/test/main_test.exs:
defmodule MainTest do
use ExUnit.Case
import ExUnit.CaptureIO
test "main/0 realiza interação com o usuário" do
output =
capture_io("100\n10\n", fn ->
Main.main()
end)
assert output =~ "Digite uma temperatura em Celsius:"
assert output =~ "Em Fahrenheit: 212.0"
assert output =~ "Digite uma distância em metros:"
assert output =~ "Em pés: 32.8084"
end
end
Para executar todos os testes, é só rodar na raiz:
mix test
Se quiser ver os testes de forma completa:
mix test --trace
Ou testar apenas um app:
cd apps/conversor_distancia
mix test
A separação por apps facilita testes unitários isolados e estimula boas práticas.
Conclusão
Neste post, criamos um projeto guard-chuva com três apps Elixir. Com ele, aprendemos a:
- Organizar um sistema modular e escalável
- Integrar múltiplos apps com dependências locais
- Utilizar
iex
,mix test
,mix compile
emix run
de forma produtiva
Além disso, reforçamos a idéia de que apps no Elixir são unidades organizacionais que permitem estruturar bem o código. Ao usar nomes como Conversor.Temperatura
e Conversor.Distancia
, deixamos clara a intenção e responsabilidade de cada parte do sistema.
Essa estrutura favorece a clareza, testes isolados e evolução contínua de sistemas mais robustos.