CTO no Integrar Plus • Publicado em 30 de September de 2026
Toda vez que uma aplicação Rails cresce o bastante para incomodar o time, a conversa descamba para o mesmo beco sem saída. Uma metade da equipe quer quebrar o sistema em microserviços para supostamente dar autonomia aos desenvolvedores. A outra metade defende empacotar cada pedaço de negócio dentro de uma Rails Engine com rotas, migrações e dependências isoladas.
Eu discordo das duas abordagens.
A migração para microserviços cobra um pedágio operacional desproporcional para a imensa maioria dos produtos. Você troca chamadas de método rápidas em memória por latência de rede, serialização JSON, consistência eventual e uma infraestrutura que exige observabilidade complexa só para rastrear uma requisição de cadastro. No nosso ambiente, rodando em servidores próprios com PostgreSQL e deploy via Kamal, introduzir esse tipo de atrito sem necessidade de escala real seria um tiro no pé.
Por outro lado, a aposta cega em Rails Engines dentro do mesmo repositório costuma gerar uma ilusão de isolamento que desmorona no primeiro mês. Quem defende engine como solução padrão costuma argumentar que ela força fronteiras físicas no disco, impedindo acoplamento indevido. O problema é que engines trazem atrito de manutenção diário. Rotas aninhadas viram um labirinto, pipelines de assets precisam de configurações especiais e o ciclo de migrações no banco perde a linearidade natural que o ActiveRecord gerencia tão bem.
O caminho que realmente se sustenta no dia a dia do Rails 8 fica no meio desse fogo cruzado, usando apenas convenções do Zeitwerk, namespaces de Ruby e uma regra muito clara sobre quem pode falar com o banco.
O Rails sempre foi complacente demais com o acesso direto a dados. Se o seu modelo Order precisa do endereço de entrega, qualquer desenvolvedor escreve order.user.addresses.primary dentro de uma view ou de um controller qualquer. Funciona rápido na primeira sprint. Dois anos depois, o domínio de faturamento depende intimamente da estrutura interna da tabela de autenticação de usuários, e ninguém mais consegue alterar uma coluna sem disparar efeitos colaterais em três telas não relacionadas.
Quebrar a aplicação em pastas separadas não conserta isso se as associações do ActiveRecord continuarem atravessando o sistema livremente. Uma engine continua acessando o mesmo banco de dados relacional e continua permitindo queries diretas entre tabelas alheias se você não policiar o código ativamente.
No Rails 8, a abordagem mais limpa que encontrei consiste em definir pastas de domínio dentro de app/ sem criar gems locais ou engines completas. Uma pasta como app/billing ou app/identity é carregada automaticamente pelo Zeitwerk sem esforço adicional de configuração.
## app/billing/services/charge_subscription.rb
module Billing
class ChargeSubscription
def initialize(gateway: PaymentGateway.new)
@gateway = gateway
end
def call(customer_reference:, amount_cents:)
token = Billing::Customer.find_by!(reference: customer_reference).gateway_token
@gateway.charge(token, amount_cents)
end
end
end
A diferença prática não está na árvore de diretórios, mas na disciplina de estabelecer uma superfície pública de contato. Um domínio só deve expor classes de comando e leitura. Tudo o que for ActiveRecord interno daquele módulo precisa ser tratado como detalhe de implementação privado.
Quando você decide que o domínio de faturamento não deve chamar User.find diretamente, surge uma dúvida legítima: como exibir dados consolidados em relatórios ou listagens sem cair no pesadelo de dezenas de queries N+1?
A resposta pregada pelos defensores ferrenhos de arquitetura limpa costuma ser a replicação assíncrona de dados via eventos. Eles querem que você escute eventos de atualização de usuário e grave cópias de nomes e e-mails dentro da tabela de faturamento. Em um sistema distribuído de verdade, talvez essa complexidade seja inevitável. Dentro de um monólito rodando em cima de um PostgreSQL bem ajustado, isso é puro desperdício de esforço e garantia de inconsistência a médio prazo.
O banco de dados ainda é único e relacional. Nada impede que consultas complexas de leitura usem views do PostgreSQL ou objetos de query dedicados que executam joins entre tabelas de domínios diferentes.
## app/reporting/queries/monthly_revenue_query.rb
module Reporting
class MonthlyRevenueQuery
def self.execute(year:, month:)
ActiveRecord::Base.connection.select_all(<<~SQL.squish)
SELECT u.organization_name, SUM(i.total_cents) AS revenue
FROM billing_invoices i
INNER JOIN identity_users u ON u.id = i.user_id
WHERE EXTRACT(YEAR FROM i.paid_at) = #{year}
AND EXTRACT(MONTH FROM i.paid_at) = #{month}
GROUP BY u.organization_name
SQL
end
end
end
A fronteira precisa proteger a lógica de escrita e as regras de negócio, não proibir o banco de relacionar dados. Se uma operação de escrita de cobrança precisa validar o status de uma conta, ela deve perguntar à interface pública do domínio de identidade através de um método Ruby explícito, sem tocar no modelo Identity::User por baixo dos panos. Se uma tela precisa listar faturas com o nome do cliente, uma query direta resolve a demanda em três milissegundos sem violar regra de negócio alguma.
Outro ponto em que muitos times se perdem é na comunicação entre contextos. A reação quase automática ao ouvir falar de limites de contexto é instalar o Kafka ou configurar tópicos no RabbitMQ.
O ecossistema moderno do Rails entrega ferramentas suficientes para manter essa integração desacoplada sem sair do processo. Para tarefas em que a sincronização imediata é indispensável, um objeto de comando que orquestra a chamada entre dois domínios resolve o problema com clareza. Para efeitos colaterais que podem acontecer fora do ciclo da requisição, o Solid Queue em conjunto com eventos internos cumpre o papel com perfeição.
## app/billing/events/subscription_activated.rb
module Billing
class SubscriptionActivated < ApplicationJob
def perform(customer_reference)
Identity::ProvisionAccess.call(customer_reference: customer_reference)
Notification::SendWelcomeEmail.call(customer_reference: customer_reference)
end
end
end
O ActiveSupport::Notifications também funciona muito bem para desacoplar ações sem acoplar chamadas diretas no código, embora eu prefira jobs explícitos quando há persistência e necessidade de garantia de entrega.
O erro mais comum ao adotar eventos internos é transformar cada mínima alteração de registro em um broadcast. Se você emite eventos para cada linha que muda no banco, o fluxo de execução se torna impossível de rastrear sem ferramentas caras de tracing. Limite os eventos aos marcos reais do negócio: pedido pago, conta suspensa, contrato cancelado.
Manter um monólito modular sem ferramentas estritas de barreira como o Packwerk exige disciplina constante no code review. Não existe mágica: se um desenvolvedor júnior entrar no time e decidir fazer um update_all cruzando três tabelas de domínios distintos dentro de um controller, o Ruby vai permitir e o Rails vai executar alegremente.
Ferramentas estáticas de análise de limites podem ajudar quando a equipe cresce além de dez pessoas, mas colocá-las no projeto cedo demais costuma criar um atrito burocrático que desacelera entregas simples. O equilíbrio que encontrei viável envolve manter convenções rígidas de nomes, isolar tabelas com prefixos claros no schema (como billing_ ou identity_) e rejeitar qualquer pull request que passe instâncias do ActiveRecord de um domínio para outro como parâmetro público.
Monólitos bem estruturados não morrem por falta de ferramentas avançadas de barreira. Eles morrem quando o time desiste de pensar sobre onde cada responsabilidade deve morar e prefere terceirizar a arquitetura para a complexidade da rede. No fim do dia, separar código por pastas e honrar interfaces mínimas dentro de um único executável entrega velocidade de desenvolvimento e facilidade operacional que nenhuma malha de microsserviços consegue igualar.
CTO e arquiteto full stack especializado em Ruby on Rails, Vue.js, sistemas multi-tenant e infraestrutura distribuída.