Evoluindo um Monólito Rails: Registry Pattern para um SaaS Multi-Vertical

Posted on August 24, 2026 by Daniel Silva
12 min read

O Sectify começou como um construtor de sites genérico. Foi encontrar tração real num nicho que eu nem tinha planejado (campanhas políticas), e a resposta natural foi o caminho de menor esforço: uma flag no banco, industry, e um if aqui, outro ali, conforme a necessidade batia na porta. Funcionou. Por um tempo razoável, na verdade. Até que parou.

Este artigo é sobre o que veio depois: como saí desse modelo e cheguei a uma arquitetura de verticais independentes, usando um Registry e classes Ruby puras para encapsular a configuração e as regras de cada nicho.

De objeto de estudo a produto real

O Sectify não nasceu como produto. Nasceu como estudo. Eu vinha de projetos com stacks espalhadas entre runtimes diferentes (React de um lado, Sidekiq dependente de Redis do outro) e queria testar até onde dava para ir ficando só dentro do Rails, na linha do "The One Person Framework".

O histórico de commits denuncia bem essa transição de laboratório para produto: Começou com Rails 8, Tailwind e SQLite. A escolha do banco já entrega a mentalidade: sem nada externo para subir, dava para focar só na modelagem de autenticação e páginas e ir rápido. O primeiro teste sério do framework veio com o live preview do editor: em vez de salvar cada alteração no banco, o editor guarda o estado no cliente e escreve direto no DOM da prévia, deixando a persistência para os momentos em que o usuário realmente aperta salvar. Funciona bem, e ainda hoje é uma das partes do código de que mais gosto.

Só que em algum ponto o projeto parou de ser estudo. Encontrou espaço de verdade no mercado, e a barra técnica subiu de uma vez só, sem aviso prévio, como costuma acontecer. Criptografia entrou na conversa. Estratégias de fallback também. RSpec passou a ser levado a sério, porque regressão em produção é um tipo de dor que você só precisa sentir uma vez para nunca mais querer sentir de novo. O banco migrou de SQLite para PostgreSQL, e o Kamal assumiu o deploy.

Hoje o Sectify roda em produção com editor de páginas, captura de leads, billing, geração de conteúdo via IA, templates variados, subdomínios e background jobs. E foi justamente esse crescimento, orgânico e sem um plano mestre definido, que me fez trombar de frente com o primeiro gargalo arquitetural sério.

1. O problema e os requisitos

O clássico if politician?. Começou inofensivo, numa view, só para trocar um título no painel administrativo. Ninguém liga para isso. Depois foi parar no controller, redirecionando usuários no login. Pouco depois a mesma checagem já estava nos services definindo limites de faturamento, filtrando quais templates de página podiam ser criados, e, como sinal de que as coisas tinham ido longe demais, mudando o system prompt da IA:
# O pesadelo começa a escalar
def system_prompt
  if Current.user.politician?
    "Você é um estrategista político focado em campanhas eleitorais..."
  else
    "Você é um especialista em marketing..."
  end
end

O problema nunca foi o if em si. Ifs existem, tudo bem. O problema era o conhecimento sobre o vertical vazando para camada atrás de camada da aplicação, sem nenhum lugar central que dissesse "é aqui que essa decisão mora". Quando ficou claro que a estratégia de produto envolvia abrir mais nichos, esse modelo virou insustentável rápido.

Três requisitos guiaram a solução, e eles eram bem concretos:
  • Isolamento de regras: Cada vertical tem seus próprios planos, templates, integrações e vocabulário. Esse conhecimento precisava parar de vazar.
  • Mesmo motor: Editor de sites, gestão de leads, infraestrutura: tudo continua sendo exatamente o mesmo. A diferença mora na configuração e nas regras, não no fluxo da aplicação. Nada de repositórios separados, engines ou a tentação sempre presente de fatiar tudo em microsserviços.
  • Escalabilidade técnica: Adicionar um vertical novo devia ser uma mudança localizada, não uma caça ao tesouro pelo repositório inteiro.

2. As decisões principais

Primeiro conceito que precisei fixar na cabeça, porque errar aqui contaminaria tudo depois: vertical não é tipo de usuário. Vertical é um conjunto de capacidades, configurações e regras que define uma instância comercial do produto. Parece sutil, mas muda completamente onde cada coisa deveria morar.

Combinei um Registry, responsável pela descoberta dos verticais, com classes Ruby puras (POROs) encapsulando a configuração e as regras de cada um:
                    Request
                       │
                       ▼
             ApplicationController
                       │
                       ▼
              Verticals::Registry
                       │
                resolve(context)
                       │
          ┌────────────┼────────────┐
          ▼            ▼            ▼
       General      Politico    Transportadora
          │            │            │
          └────────────┼────────────┘
                       ▼
                Current.vertical
                       │
          ┌────────────┼─────────────┐
          ▼            ▼             ▼
        Views       Billing          AI

O arquivo app/verticals/base.rb define o contrato que toda implementação precisa assinar. Nada de regra específica aqui, apenas a interface obrigatória, sem exceção:
module Verticals
  class Base
    # Identificação
    def self.key; raise NotImplementedError; end
    def self.subdomain; raise NotImplementedError; end

    # UI & Funcionalidades
    def self.available_sections; raise NotImplementedError; end
    def self.page_templates; raise NotImplementedError; end

    # Billing
    def self.plans; raise NotImplementedError; end

    # Inteligência Artificial
    def self.ai_system_prompt; raise NotImplementedError; end
  end
end

Preferi falhar explicitamente com NotImplementedError quando um vertical não implementa alguma parte do contrato, em vez de deixar uma implementação incompleta explodir silenciosamente só quando aquele método específico for chamado no meio de uma requisição real. Prefiro o erro no boot do que o erro na cara do usuário.

Implementando um vertical
Com o contrato fechado, mapeei os domínios. O produto base virou o vertical General; o produto político foi extraído para Politico. Tudo centralizado, fácil de ler de cima a baixo:
# app/verticals/politico.rb
module Verticals
  class Politico < Base
    def self.key = :politico
    def self.subdomain = "politico"

    def self.available_sections
      # O vertical limita as seções disponíveis no criador de sites
      [:hero, :biography, :agenda, :campaign_proposals, :donation, :volunteer]
    end

    def self.plans
      # Os planos são versionados junto ao vertical porque representam
      # a oferta estrutural comercial daquele produto.
      {
        bronze_tier: { pages: 3, price: 9000, ... },
        silver_tier: { pages: 10, price: 19000, ... }
      }
    end

    def self.ai_system_prompt
      "Você é um estrategista político focado em campanhas eleitorais..."
    end
  end
end

O maestro: Registry Pattern e Zeitwerk
Para centralizar a descoberta, construí o Verticals::Registry, com o método resolve(subdomain:, user:). A precedência é direta, sem cerimônia: primeiro checa se o subdomínio atual pertence a um vertical específico; se não, tenta o vertical salvo no perfil do usuário logado; e, se nada disso resolver, cai no General como padrão.

A integração com o Zeitwerk ficou em config/initializers/verticals.rb:
Rails.autoloaders.main.push_dir(
  Rails.root.join("app/verticals"),
  namespace: Verticals
)

O eager load aqui garante que as classes dos verticais estejam carregadas já no boot. Isso deixa o Registry com todos os verticais disponíveis desde a inicialização, faz a aplicação falhar cedo se alguma configuração estiver inválida, e permite iterar sobre os verticais para gerar rotas dinâmicas sem gambiarra.

Resolução de estado global
Depois de resolvido o usuário, injeto a resolução do vertical logo no topo da cadeia, no ApplicationController, guardando o resultado em Current.vertical via ActiveSupport::CurrentAttributes:
class ApplicationController < ActionController::Base
  before_action :set_current_vertical

  private

  def set_current_vertical
    Current.vertical = Verticals::Registry.resolve(
      subdomain: request.subdomain,
      user: Current.user
    )
  end
end

O efeito prático aqui é: a pergunta que o sistema faz muda de figura. Antes era "quem é esse usuário?" (if user.politician? renderiza o bloco X). Agora é "o que esse ambiente permite?" (if Current.vertical.available_sections.include?(:agenda) renderiza o bloco X). Parece pouco no papel. Na prática, é a diferença entre um código que você entende de cabeça e um que você só entende com o debugger aberto.

3. Trade-offs e alternativas consideradas

Antes de martelar essa arquitetura, passei pelas opções óbvias do ecossistema Ruby/Rails. Vale registrar por que descartei cada uma, porque nenhuma delas é inerentemente errada, apenas inadequadas para esse contexto específico.

  • Configuração via JSON/YAML: Resolveria bem a parte de dados, mas comportamento é outra história. Algumas configurações precisam ser compostas, derivadas, ou compartilhar lógica comum entre si. Um arquivo estático não faz isso sozinho, me obrigando a construir uma camada extra só para validar e interpretar o próprio contrato. Preferi já nascer em Ruby.
  • Rails Engines: Fariam sentido se os verticais fossem domínios independentes de verdade, com código, rotas e responsabilidades isoladas. Não é o caso aqui. Os verticais do Sectify ainda compartilham o mesmo modelo de dados e a maior parte do fluxo da aplicação, então separar em engines só adicionaria complexidade de manutenção sem resolver nada que já não estivesse resolvido.
  • STI no model de User: Tentador à primeira vista, errado na prática. O vertical não é um subtipo de usuário, mas sim o contexto de produto em que aquele usuário está operando. Amarrar regras de interface e faturamento à classe do Active Record seria vazar domínio comercial para a camada errada, exatamente o problema que eu estava tentando resolver, só que disfarçado.

Dito isso: eu não recomendaria esse padrão se os verticais precisassem de modelos de dados completamente distintos, fossem mantidos por equipes separadas com ciclos de deploy próprios, ou exigissem isolamento regulatório rígido entre si.

4. Execução da refatoração: "Comendo o boi a bife"

Refatorações estruturais têm um jeito de virar Pull Requests de cinco mil linhas que ninguém, honestamente, consegue revisar de verdade. Optei por uma divisão em cinco estágios:

  1. A fundação: Criação do Registry, do contrato (Base) e a migration renomeando a coluna industry para vertical. 
  2. Roteamento e autenticação: Injeção do Current.vertical no ciclo de vida da requisição e roteamento dinâmico no routes.rb, iterando sobre os verticais para gerar constraints por subdomínio. Também ajustei a autenticação para funcionar cross-subdomain: se um usuário loga no subdomínio geral, mas a conta está atrelada ao vertical Político, ele é redirecionado automaticamente, sem precisar entender por que caiu no lugar errado.
  3. Apresentação (views e I18n): Remoção dos ifs nas views, um por um. A parte mais valiosa disso tudo foi extrair a terminologia: dicionários isolados como locales/verticals/politico.pt-BR.yml mantiveram o vocabulário de cada vertical fora do core, onde ele nunca deveria ter estado.
  4. Camada de serviços: Refatoração do SchemaBuilderService, responsável pela composição do schema usado pela IA, e dos controllers de assinatura, agora consultando dinamicamente a configuração do vertical ativo em vez de perguntar quem é o usuário.
  5. A grande limpeza: Um PR final, destrutivo e satisfatório (desses que dá gosto de mergear), dedicado a rodar grep -r "politician?" e caçar os resíduos que sobraram.
Verticals::Registry.each_with_subdomain do |vertical|
  constraints subdomain: vertical.subdomain do
    get "/", to: "verticals/home#index"
  end
end

Graças ao isolamento de responsabilidades, os estágios 3 e 4 puderam avançar em paralelo, o que ajudou a manter o ritmo sem virar uma refatoração eterna.

5. O que ficou e o próximo teste

A infraestrutura de produção hoje é enxuta, sem luxo desnecessário: Kamal gerenciando as instâncias, PostgreSQL segurando o banco principal, Solid Queue cuidando dos background jobs, testes contínuos rodando via CI a cada push.

O maior acerto dessa jornada inteira, sinceramente, não esteve no código de produção. Esteve na camada de testes. Usei Shared Examples do RSpec para validar: o contrato checa invariantes estruturais e as regras mínimas que todo vertical precisa respeitar, sem exceção:
RSpec.shared_examples "a vertical" do
  it "complies with the structural contract" do
    expect(described_class.key).to be_a(Symbol)
    # General não tem subdomínio explícito
    expect(described_class.subdomain).to be_present.or be_nil
    expect(described_class.available_sections).to all(be_a(Symbol))

    # Valida que todos os planos têm estrutura de preço e páginas
    expect(described_class.plans.values).to all(
      include(:pages, :price)
    )
  end
end

# Validando o contrato de um novo vertical em poucas linhas:
RSpec.describe Verticals::Transportadora do
  it_behaves_like "a vertical"
end

Isso significa que, quando eu adicionar o próximo vertical, o RSpec me avisa na hora se esqueci de algo essencial. Não preciso confiar na minha própria memória para isso, o que é sempre uma boa notícia.

Outra vitória, menos glamorosa mas igualmente útil: usar prepend_view_path dinamicamente. Quando a interface de um vertical diverge muito do padrão, isso evita poluir a view principal com condicionais visuais. Basta instruir o Rails a injetar a pasta de views daquele vertical no início da fila de busca:
# ApplicationController
before_action :set_view_path

private

def set_view_path
  prepend_view_path("app/views/verticals/#{Current.vertical.key}")
end

O teste de fogo de verdade para essa infraestrutura ainda não aconteceu: vai acontecer quando eu plugar o terceiro grande nicho do Sectify. O que antes exigiria mapear condicionais espalhadas por dezenas de arquivos agora se concentra numa única classe Ruby, ponto central de definição do vertical novo. Um monólito Rails bem encapsulado continua evoluindo junto com o negócio sem precisar de decomposição prematura em serviços. O ganho aqui nunca veio de adicionar infraestrutura, mas de deixar explícitas as fronteiras que o próprio crescimento do produto já tinha desenhado, quer eu quisesse enxergar ou não.