Esse post faz parte da série Aprendendo Elixir

Neste segundo post da série Aprendendo Elixir 1, vamos explorar como organizar projetos maiores usando arquitetura modular com uma estrutura guarda-chuva (chamada Umbrella pelo ecossistema Elixir). A proposta é dividir o sistema em múltiplas aplicações menores, focadas e coesas, que podem ser desenvolvidas, testadas e integradas dentro de um mesmo repositório sob o comando do Mix. Esse modelo segue o princípio de separação de responsabilidades, facilitando a manutenção e a escalabilidade do código.

Os exemplos deste post estão disponíveis na pasta 02-umbrella do repositório de estudos. Para demonstrar o conceito na prática, construímos um conversor unificado composto por três aplicações internas: o conversor_temperatura (cálculos entre Celsius e Fahrenheit), o conversor_distancia (conversões entre metros e pés) e o app integrador main, responsável por orquestrar as entradas do usuário no terminal.

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 negócio independentes (como APIs, persistência e workers em background) e gerenciar dependências locais entre subprojetos sem a necessidade de publicar pacotes no Hex.

Cada subaplicação mantém seu próprio ciclo de vida, arquivos de configuração e suíte de testes unitários isolados, ao mesmo tempo em que podem ser carregadas e executadas conjuntamente a partir da raiz. O Elixir lida com essa organização com muita naturalidade graças à arquitetura de aplicações OTP da BEAM 2.

Criando o projeto guarda-chuva

Para criar o projeto guarda-chuva principal no terminal, execute:

mix new conversor --umbrella
cd conversor

Cria a estrutura base do projeto umbrella e entra no diretório raiz da aplicação.

A flag --umbrella inicializa a estrutura com a pasta apps/, onde viverão todas as nossas subaplicações.

Aviso

A partir daqui, sempre que falarmos da raiz do projeto, estamos nos referindo a esta pasta inicial criada com o comando acima.

Agora, dentro do diretório apps, criamos os três subprojetos independentes:

cd apps
mix new conversor_distancia
mix new conversor_temperatura
mix new main

Inicializa cada uma das três subaplicações dentro da pasta apps.

Estrutura do projeto

A árvore de diretórios resultante para os arquivos que iremos trabalhar fica assim:

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 listagem resume apenas os arquivos principais de lógica e teste para mantermos o foco didático.

O arquivo mix.exs 3 da raiz define que o projeto é do tipo umbrella através da opção apps_path: "apps", centralizando a compilação e a execução dos testes.

Implementando os apps

Módulo Conversor.Distancia

Este app lida exclusivamente com conversões métricas entre metros e pés.

Arquivo: 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

Implementa funções de conversão métrica com guards numéricos e doctests de precisão.

Módulo Conversor.Temperatura

Este app implementa as fórmulas de conversão entre escalas termométricas:

Arquivo: 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

Implementa conversões de temperatura entre escalas Celsius e Fahrenheit.

Nota

Uma convenção comum e elegante no Elixir é usar nomes com namespaces modulares, como Conversor.Temperatura e Conversor.Distancia. Isso organiza a estrutura de pacotes, evita conflitos de identificadores e esclarece imediatamente o domínio de cada função.

App Integrador: Main

Para consumir nossos módulos, implementamos um app principal chamado main para rodar diretamente no terminal com captura de input interativo.

No mix.exs do app main, declaramos a dependência local para os outros dois subprojetos via in_umbrella: true:

Arquivo: apps/main/mix.exs

defp deps do
  [
    {:conversor_temperatura, in_umbrella: true},
    {:conversor_distancia, in_umbrella: true}
  ]
end

Declara dependências internas do projeto guarda-chuva utilizando a opção in_umbrella.

Nota

Em projetos umbrella, cada app vive isolado em sua própria pasta, mas todos compartilham o mesmo ambiente de execução na compilação. Ao adicionar in_umbrella: true, os módulos dos apps dependentes ficam imediatamente disponíveis no namespace sem necessidade de configurações adicionais.

O módulo interativo de terminal:

Arquivo: 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("#{celsius}°C equivalem a #{Float.round(fahrenheit, 2)}°F")

    IO.puts("\nDigite uma distância em metros:")
    metros = get_float_input()
    pes = Conversor.Distancia.m_para_ft(metros)
    IO.puts("#{metros}m equivalem a #{Float.round(pes, 2)}ft")
  end

  defp get_float_input do
    case IO.gets("> ") |> String.trim() |> Float.parse() do
      {valor, _} ->
        valor

      _ ->
        IO.puts("Entrada inválida, tente novamente.")
        get_float_input()
    end
  end
end

Orquestra as chamadas aos conversores recebendo entradas do usuário e tratando parsing inválido recursivamente.

Aviso

Em aplicações corporativas dificilmente usaremos loop de console interativo dessa forma; trata-se apenas de um exemplo simples para demonstrar a orquestração e execução de múltiplos módulos locais.

Compilação e execução standalone

Para compilar todos os subprojetos da árvore ao mesmo tempo, execute na raiz:

mix compile

Compila todo o projeto umbrella gerando os binários unificados dentro do diretório _build.

Para executar o ponto de entrada da aplicação, temos três abordagens práticas:

Usando mix run

A forma mais direta de rodar uma função específica a partir da linha de comando:

mix run -e 'Main.main'

Executa a função Main.main/0 carregando todo o contexto do projeto umbrella.

Usando Aliases no Mix

Podemos definir atalhos customizados no arquivo mix.exs da raiz:

Arquivo: mix.exs

defp aliases do
  [
    conversor_main: "run -e 'Main.main'"
  ]
end

Configura um alias customizado no Mix apontando para o comando de execução.

Adicione também a opção aliases: aliases() na função project do mix.exs. Feito isso, basta rodar:

mix conversor_main

Executa o alias definido no Mix chamando a função principal do console.

Usando Mix.Tasks

Outra abordagem flexível é criar uma Mix Task dedicada em apps/main/lib/mix/tasks/conversor_task.ex 4:

Arquivo: 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

Define uma tarefa Mix oficial para inicializar a aplicação e disparar a rotina interativa.

Com a task declarada, você pode invocá-la normalmente pelo Mix:

mix conversor_task

Roda a Mix Task recém-criada a partir de qualquer nível do projeto.

Dica

Ao rodar mix help | grep conversor, o Mix listará automaticamente a sua task com o resumo que você definiu no @shortdoc.

Usando IEx com projetos umbrella

Você pode carregar todas as aplicações no console interativo a partir da raiz com:

iex -S mix

Abre o shell IEx com todos os subprojetos e dependências compilados e disponíveis.

Com isso, podemos testar os módulos diretamente no terminal:

iex> Conversor.Temperatura.f_para_c(212)
100.0
iex> Conversor.Distancia.m_para_ft(10)
32.8084

Testa interativamente chamadas aos módulos de temperatura e distância no IEx.

Testes Automatizados

Vamos adicionar testes com ExUnit 5 para cada aplicação. Usaremos geradores aleatórios controlados pela seed do ExUnit para testar intervalos numéricos com precisão flutuante.

Testes para Conversor.Distancia

Arquivo: 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

Valida conversões de distância com delta de precisão em ponto flutuante sobre entradas aleatórias.

Testes para Conversor.Temperatura

Arquivo: 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

Testa as fórmulas de conversão termométrica cobrindo números positivos e negativos.

Teste para a Chamada Interativa de Main

Arquivo: 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

Utiliza CaptureIO para simular entrada de usuário no terminal e validar as saídas geradas pelo app.

Para rodar todos os testes de todas as subaplicações em paralelo:

mix test

Executa os testes de todos os subprojetos a partir da raiz do umbrella.

Se quiser inspecionar cada teste individualmente:

mix test --trace

Executa a suíte detalhando cada caso de teste executado no terminal.

Ou executar os testes de apenas um dos subapps:

cd apps/conversor_distancia
mix test

Executa os testes isoladamente dentro da pasta do subprojeto específico.

Conclusão

Neste post, criamos um projeto guarda-chuva completo com três apps Elixir. Vimos como modularizar funcionalidades com responsabilidades bem divididas, integrar subprojetos por meio de dependências locais in_umbrella e operar ferramentas essenciais como iex, mix test, mix compile e criação de tasks customizadas.

A estrutura de projetos guarda-chuva reforça a clareza e a manutenibilidade do código, permitindo que cada parte do sistema evolua com sua própria suíte de testes sem perder a coesão do ecossistema geral.

Referências

  1. Documentação oficial do Elixir {Elixir Lang} (Link

  2. Umbrella Projects {Elixir School} (Link

  3. Guia oficial do Mix {HexDocs} (Link

  4. Custom Mix Tasks {Elixir School} (Link

  5. Testing {Elixir School} (Link