Camadas do app Rails (modules/backend)

TLDR: o app/ segue o layout padrão do Rails — models, controllers, jobs, serializers, use_cases e services — como define o commons:rails 2.1. Controllers e jobs chamam um use case para toda ação de negócio, e controllers podem consultar models direto (Customer.find, Customer.search); use cases são ações de negócio que devolvem Success/Failure com objetos de negócio; models são ricos; services/ é só integração externa.

Contexto

Este serviço orquestra Asaas, Apolo, Kommo e Slack, e a dor central do produto é o vocabulário desses sistemas ter vazado para dentro da operação. A arquitetura do back existe para manter o negócio escrito no nosso vocabulário e os terceiros confinados a uma camada de tradução.

O modelo é o layout padrão do Rails mais duas pastas, use_cases/ e services/, com um papel fixo para cada camada:

  • Entradas (controllers/, jobs/) recebem a requisição ou o agendamento, chamam um use case e reagem ao resultado. Não decidem nada. Controllers podem consultar models direto (find, search, escopos) para ler e listar.
  • Use cases (use_cases/) são as ações de negócio, nomeadas como o negócio fala (Debit::Renegotiate). Decidem quando algo acontece e o que mais acontece, e devolvem Success/Failure com objetos de negócio.
  • Models (models/) são ricos: definem como o próprio estado muda — cálculos, transições de status, invariantes — sem sair dos próprios dados e objetos diretos.
  • Services (services/) são tradutores: um módulo por terceiro, que recebe e devolve o nosso vocabulário e nunca decide negócio.
  • Serializers (serializers/) só dão forma à resposta.

O conceito central é use case + result. u-case (Micro::Case) é a implementação atual; trocá-la não muda a arquitetura. Este documento aplica o commons:rails; em caso de conflito, ele vence.

Decisão

Layout padrão do Rails:

app/ controllers/ → entrada HTTP: autentica, extrai params, consulta models ou chama um use case, renderiza — zero ifs de negócio jobs/ → entrada de background: mesma forma dos controllers models/ → ricos: mexem só nos próprios dados e objetos diretos use_cases/ → ações de negócio: <Contexto no singular>::<Verbo> (ex: Charge::Recalculate) services/ → só integrações externas: um módulo por terceiro (Asaas, Apolo, Kommo, Slack), nunca decide negócio serializers/ → forma da resposta; não decide nada mailers/ → e-mail de saída; só o use case dispara (deliver_later)

Controllers de API ficam sob controllers/api/v1/, com Api::V1::BaseController cuidando de autenticação JWT e do formato de erro.

mermaid graph LR HTTP["HTTP / cron"] --> PO["controllers · jobs"] PO --> UC["use_cases"] UC --> MO["models"] PO -.->|consulta| MO UC --> SV["services/<br/>Asaas · Apolo · Kommo · Slack"] UC -.->|perform_later| JO["jobs"] SV --> EXT["APIs externas"] PO --> SE["serializers"] style UC fill:#1f2937,color:#fff style SV fill:#374151,color:#fff

A direção da seta é única: controllers/jobs chamam use_cases, use_cases chamam models e services e podem enfileirar jobs; controllers também leem models. Nada volta. A regra é convencional — a pasta não impede, a revisão (commons:review) cobra.

Controllers

Controllers podem consultar models direto quando a action só lê: buscar um registro (Customer.find(params[:id])), listar ou filtrar (Customer.search(scope, params), escopos, includes, paginação) e passar o resultado ao serializer. Um use case que só repassaria a consulta não traz nada.

O limite é a escrita e a decisão:

  • Toda action que muda estado (create, update, destroy, transições de status) chama um use case e renderiza pelo result
  • A regra de busca mora no model (Customer.search, escopos), nunca montada em wheres no controller
  • Escolher o escopo pelo perfil do usuário (current_user.admin? ? Contract.all : ...) é autorização e pode ficar no controller; regra de negócio não
  • Controller nunca chama service

```ruby # GOOD — leitura direta, escrita por use case class CustomersController < BaseController def index render json: Customer.search(Customer.all, params), each_serializer: CustomerSerializer end

def show render json: Customer.find(params[:id]), serializer: CustomerSerializer end

def update Customer::Update.call(customer: Customer.find(params[:id]), attributes: customer_params) .on_success { |result| render json: result[:customer], serializer: CustomerSerializer } .on_failure(:invalid) { |result| render_error(:unprocessable_entity, “invalid”, “Dados inválidos”, result[:errors]) } end end ```

Use cases

  • Uma classe = uma ação de negócio, com contexto no singular: Charge::Recalculate, Debit::Renegotiate, Dashboard::Summarize. O contexto pode ou não ser um model
  • Sempre declarada na forma compacta class Debit::Renegotiate < Micro::Case. Quando o contexto é um model, module Debit quebra com TypeError: Debit is not a module; a forma compacta funciona nos dois casos (o Zeitwerk usa a classe do model ou cria o módulo a partir da pasta)
  • Success carrega objetos de negócio (models, value objects), nunca JSON
  • Todo Failure tem tipo de negócio (:already_listed, :charge_rejected)
  • Todo erro de service vira Failure, capturando só a hierarquia do terceiro (rescue Asaas::Error); indisponibilidade vira Failure(:asaas_unavailable) e o job faz retry_job

Models

Ricos, desde que mexam só nos próprios dados e nos objetos diretos (has_many/has_one; belongs_to só leitura). O model define como o próprio estado muda (debit.recalculate!); o use case decide quando e o que mais acontece. Model nunca chama service, job, mailer ou use case.

Services

O primeiro service é o Apolo (app/services/apolo.rb e app/services/apolo/): Apolo.find_student(email:) devolve Apolo::Student/Apolo::Enrollment (Data.define), falha com Apolo::Unavailable/Apolo::Rejected, e o Faraday só aparece em Apolo::Client. As próximas integrações seguem este formato.

Módulo Ruby por terceiro com operações, como um SDK: Asaas.create_payment(...). Recebe e devolve o nosso vocabulário (value objects com Data.define); o vocabulário do terceiro (billingType, dueDate) só aparece dentro de services/<terceiro>/. Falha levanta a hierarquia do terceiro (Asaas::Unavailable, Asaas::Rejected); erro da gem HTTP nunca sai de services/.

Autoload

Nenhuma configuração. Todas as pastas são filhas diretas de app/, então o Zeitwerk já as trata como autoload roots por padrão — inclusive use_cases/ e services/. config/application.rb fica só com config.autoload_lib(ignore: %w[assets tasks]).

app/models/user.rb define User, como em qualquer app Rails.

Testes

test/ espelha app/: test/models/, test/controllers/, test/use_cases/, test/services/, test/jobs/.

Consequências

Consequência Efeito
Zero configuração de autoload Pasta nova sob app/ funciona sem tocar em config/application.rb
Ferramentas funcionam por convenção annotate_rb, generators e ruby-lsp acham os arquivos sozinhos
Fronteiras são convencionais Nenhuma pasta impede um if de negócio em services/ ou num controller; quem barra é a revisão (commons:review)
services/ tem um papel só Só integrações externas, por terceiro; transporte assíncrono (eventos, filas) ainda não tem definição e não mora em services/

Desvios do código atual

Levantamento de 2026-09-28. O código foi escrito antes do commons:rails 2.0 e ainda não foi ajustado; cada item é pendência separada.

Desvio Onde Regra do commons:rails
Use cases declarados com module aninhado todos os 13 em app/use_cases/ forma compacta class Contexto::Verbo < Micro::Case
Contexto no plural para contornar o TypeError com o model Debits::TransferPortfolio, Contracts::AvailableActions contexto no singular (Debit::, Contract::) com forma compacta
Nome que não é ação de negócio Contracts::AvailableActions, Dashboard::Attendants, Dashboard::Evolution; Authenticate::Login usa verbo como contexto <Contexto>::<Verbo> (ex: Dashboard::Summarize, Authentication::Login)
Serializer chamando use case ContractSerializer#actions chama Contracts::AvailableActions serializer só formata; regra que depende só do status do contrato vai para o model (contract.available_actions)
Mixin de use case fora de pasta padrão app/concerns/dashboard/period_filterable.rb (Dashboard::PeriodFilterable) o commons:rails não prevê app/concerns/; decidir entre step privado ou método de model/escopo
Use case sem chamador Debits::TransferPortfolio só é chamado pelos testes apagar arquivo sem uso ou ligá-lo ao fluxo que precisa dele
Use cases sem teste Contracts::AvailableActions, Dashboard::Evolution, Profile::Update test/ espelha app/

Referências