Versão revisada em inglês aqui: REPLACEME

Fala, turma! Espero que estejam bem.

Então, você está construindo um aplicativo e chegou aquele momento em que pensa: “Droga, como faço para manter os dados da Empresa Dois irmãos, completamente separados dos dados da Os Primos?”. Talvez você esteja lidando com registros médicos, dados financeiros ou apenas está passando por aqui pra ver onde vou chegar.

À medida que um aplicativo cresce em popularidade e uso, você precisará escalá-lo para oferecer suporte aos seus novos usuários e seus dados. Uma maneira pela qual seu aplicativo pode precisar ser escalado é no nível do banco de dados. O Rails suporta o uso de vários bancos de dados, então você não precisa armazenar seus dados todos em um só lugar.”

A aplicação exemplo é a HealthCare Management, um aplicativo para Sistema de Gestão de Práticas de Saúde que possui alguns pontos críticos em relação ao isolamento e acesso aos dados. Vou mostrar como cada “consultório/Practice” tem seus próprios bancos de dados isolados, porque às vezes a melhor maneira de manter os dados separados é literalmente mantê-los separados.

Criei uma estrutura mínima para conseguir exemplificar como vamos trabalhar com mais de um banco de dados e quais problemas vamos resolver (e quais vamos criar) seguindo essa abordagem. Aqui uma representação do que temos até o momento:

Uma Practice refere-se à organização e gestão de serviços de saúde, abrangendo vários modelos de propriedade, como consultório particular, consultório conjunto etc., e ela vai fornecer o isolamento que precisamos em passos mais adiante.

A camada de autenticação utiliza o modelo de User integrado do Rails 8, que se conecta a um modelo de Staff que contém informações específicas da Practice, como funções, números de licença e atribuições departamentais.

Os Appointment servem como a entidade central de agendamento, vinculando um membro da Staff (como os provedores/profissionais de saúde) a um Patient. O Patient contém informações sensíveis e é onde queremos trabalhar melhor esse isolamento dos dados.

Multiple Databases

Boa parte do que vai ser feito aqui foi seguindo o Active Record Multiple Databases e é importante frisar que não vou detalhar a implementação inicial do projeto.

Então o que faremos é adicionar mais um banco de dados especificamente para lidarmos com os Patients, você pode checar o código aqui

Não fazemos nenhuma mudança para o nosso banco primary, mas adicionamos uma nova configuração para o que desejamos e percebam que também podemos configurar uma conexão diferente para esse novo banco, o que pode ser interessante se precisarmos granularizar os acessos.

  patients:
    <<: *default
    database: storage/production_patients.sqlite3 # Nome do banco
    username: patients_root # Usuário e senha para conexão
    password: <%= ENV['PATIENTS_ROOT_PASSWORD'] %>
    migrations_paths: db/patients_migrate # Caminho para as migrações

Também é necessário modificarmos todas as Models que farão conexão ao novo banco, no nosso caso apenas a Patient no momento. Precisamos fazer a conexão entre ela e uma nova abstração da ApplicationRecord

# app/models/patients_record.rb
class PatientsRecord < ApplicationRecord
  self.abstract_class = true

  connects_to database: { writing: :patients }
end

# app/models/patient.rb
class Patient < PatientsRecord
...

É importante conectar-se ao seu banco de dados em um único modelo e, em seguida, herdar as tabelas desse modelo, em vez de conectar vários modelos individuais ao mesmo banco de dados. Os clientes de banco de dados têm um limite para o número de conexões abertas e, se você fizer isso, multiplicará o número de conexões, já que o Rails usa o nome da classe do modelo para o nome da especificação da conexão.

Nesse momento podemos rodar um rails db:create que as novas tabelas vão ser criadas. É necessário modificarmos todos os ambientes para que tudo funciona corretamente, inclusive o ambiente de test se não a referência nova para Patient < PatientsRecord não irá funcionar.

Deve ter percebido que as novas tabelas que vamos criar, vão fazer referência a pasta que a pasta que configuramos com migrations_paths: db/patients_migrate. Obviamente se essa migração vai acontecer com dados que já existem em produção, será necessário fazer um dump e carregarmos eles para essa nova intância de banco. No nosso caso seria possível mover a migrações necessária para a nova pasta, mas com certeza é melhor gerarmos uma nova para mantermos esse histórico e depois de uma migração concluida com sucesso, podemos subir novas migrações deletando o que é necessario do nosso banco primary.

Vou criar a migração com os mesmos campos, mas dessa vez podemos incluir a flag com o banco de dados alvo: rails g migration CreatePatients ... --database patients Vou ajustar algumas validações manualmente que já possuíamos na outra migração para mantermos a consistência. Então agora podemos executa-lás e verificamos se os testes estão passando…

bin/rails aborted!
ActiveRecord::StatementInvalid: SQLite3::SQLException: no such table: main.practices: (ActiveRecord::StatementInvalid)

Estamos tentando usar uma chave-estrangeira em um banco que não possui a tabela para ser possível que esse compartilhamento, então como proceder? Na nossa nova migration será necessário apenas remover a foreign_key: true. Depois disso os Appointments que também possuem uma relação com ela precisam ser atualizados, então vamos criar uma migration para isso:

class RemoveForeignKeyFromAppointmentsPatient < ActiveRecord::Migration[8.1]
  def change
    remove_foreign_key :appointments, :patients
  end
end

Então nesse pontos os nossos testes voltam a passar e temos cada banco cuidando de uma visualização:

Primary Database (primary.sqlite3):
├── practices  ←──┐
├── staffs        │ (Chaves-estrangeiras ainda funcionam com o mesmo DB)
├── appointments ←┘
└── users

Patients Data Database (patients.sqlite3):
└── patients ← (Sem uso da chave-estrangeira para Appointments)

Ainda podemos usar a associação do ActiveRecord normalmente, mas não teremos as restrições que uma Chave-Estrangeira garante no nível do banco e, ainda mantemos o registro dessa chave em cada tabela, mas que funciona apenas para mantermos esse registro do valor de cada id. Então como tudo nessa vida, perdemos Integridade Referencial e prevenção de dados orfãos deveram ser gerenciados pela camada da aplicação.

Practice.first.patients # Funciona normalmente
Patient.first.practice  # Funciona normalmente

Leitor fominha

Pode acontecer de termos a necessidade de uma tabela ou um banco específico ter muita demanda em leitura ou queremos manter um perfil de acesso apenas com leitura para dados mais sensíveis, então é possível criarmos uma réplica dele.

Para o nosso exemplo estamos usando SQLite e o padrão é que o WAL mode esteja ativado, com isso possuímos múltiplos leitores mas sempre apenas um processo de escrita. Não quero entrar muito em detalhes sobre o SQLite em si, o ecosistema Rails como um todo tem sugerido ele como o padrão mas caso tenha dúvidas, essa leitura é bem bacana O que você precisa saber sobre SQLite.

Precisamos então adicionar uma nova configuração para os nossos bancos, lembrando de atualizarmos também no ambiente de desenvolvimento, senão todas as leituras vão acertar uma tabela inexistente. Você pode checar o código aqui

# config/database.yml
  patients_replica:
    <<: *default
    # database: storage/production_patient_replica.sqlite3 # Outro arquivo
    database: storage/production_replica.sqlite3 # Mesmo arquivo do banco principal
    username: patients_readonly
    password: <%= ENV['ROOT_READONLY_PASSWORD'] %>
    replica: true # Isso mostra para o Rails que é uma replica apenas de leitura

Percebam que podemos tanto configurar essa réplica para apontar exatamente para o mesmo banco que o principal (de escrita) e pra esse caso o maior benefício seria a Mudança automática de leitura/escrita e restringir apenas a permissões de leitura para um usuário específico.

Ou então para um outro banco e com isso ganhamos mais isolamento ainda, mas de alguma forma precisamos escrever ou executar atualizações regulares no outro banco/arquivo dependendo das necessidades do projeto, mas hoje não vou cobrir essa parte.

Também precisamos adicionar algumas configuração para guiarmos a aplicação para onde vamos direcionar as escritas e leituras:

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  # Configuração de divisão para leitura/escrita sem réplica
  connects_to database: { writing: :primary, reading: :primary }

# app/models/patients_record.rb
class PatientsRecord < ApplicationRecord
  # Configuração de divisão para leitura/escrita com réplica
  connects_to database: { writing: :patients, reading: :patients_replica }

Precisamos ativar a Mudança automática entr leitura/escrita executando o rails g active_record:multi_db e descomentando as linhas:

# config/initializers/multi_db.rb
Rails.application.configure do
  config.active_record.database_selector = { delay: 2.seconds }
  config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
  config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
end

Lembrando que quando executamos um comando como rails db:create, ele criará o banco de dados primário e o patients, mas precisaremos criar manualmente os usuários que terão acesso de apenas leitura em produção (pra desenvolvimento eu não adicionei a necessidade de outros usuário apenas para facilitar).

Vizinhança amigável

Um tenant pode ser um usuário individual, mas, mais frequentemente, é um grupo de usuários — como uma organização de clientes — que compartilham acesso e privilégios comuns dentro da instância do aplicativo. Os dados de cada tenant são isolados e invisíveis para os outros tenants que compartilham a instância do aplicativo, garantindo a segurança e a privacidade dos dados para todos os tenants.

O ecossistema do Rails está só crescendo e a basecamp tem criado cada vez mais ferramentas interessantes para impulsionar isso e, uma delas é a ActiveRecord::Tenanted. Nós temos sim algumas gemas por aí que já desempenhavam esse papel, como a acts_as_tenant e a apartment, mas elas não cobrem todos os casos que a ActiveRecord::Tenanted quer abraçar (que por ora, apenas funciona com o sqlite3 adapter).

Quero enfatizar que a talk do Mike Dalessio - Multi-Tenant Rails: Everybody Gets a Database! explica muito bem tudo que eles propõem com a gema.

O ActiveRecord::Tenanted estende o framework Rails para permitir que uma aplicação tenha vários bancos de dados específicos de cada Tenant. Ele fornece isolamento de dados separando logicamente os dados de cada Tenant, fornecendo mecanismos de segurança para ajudar a garantir o uso seguro do Active Record e modificando o comportamento de muitas partes do Rails, como cache de fragmentos, Active Job, Action Cable, Active Storage, Global ID e tarefas de banco de dados. Ao fornecer suporte de framework integrado para locação, o Active Record Tenanted garante que os desenvolvedores possam escrever a maior parte de seu código como se estivessem em uma aplicação de Tenant único, sem colocar em risco a privacidade e a segurança dos dados do locatário.

A documentação ainda está em construção, então vamos aprender enquanto escavamos um pouco. Tenha em mente que se a model que gostaria que orquestrasse isso já existe, ou mesmo que a criação de novos Tenants seja manual teremos que ter uma estratégia para migração, mas hoje não cuidaremos disso.

No geral (até pra outras gemas do ecossistema), quando criamos um novo Tenant um outro banco para cada instância será inicializado, e até aí tudo bem, mas eu tive alguns desafios porque eu queria usar o cadastro de Practice para orquestrar a criação dos Tenants, pensando que para a nossa aplicação, sempre que um novo consultório adotasse o nosso sistema, seria apenas criarmos esse novo Practice para que a mágica acontecesse, mas não só isso, também queria manter o isolamento dos Patients para seguirmos a abordagem que iniciamos, mas por hora, essa adaptação custou um pouco e vou explicar os porquês.

Precisei manter alguns registros em um banco centralizado “global” para o Practice e também o Session para que seja possível um mesmo usuário trabalhar em diversos lugares usando a mesma conta.

ActiveRecord::Base
├── GlobalRecord (gravação/leitura de banco de dados global)
│    ├── Practice (gerencia os Tenants, cria/destroi novos Tenants databases)
│    └── Session (Sessões de Autenticação)
├── ApplicationRecord (banco tenanted :primary)
│    ├── User 
│    ├── Staff 
│    └── Appointment
└── PatientsRecord (banco tenanted :patients)    
     └── Patient

Então, depois de instalarmos a gema, é bom frisar que estamos na versão 0.3.0 no momento. Temos que ajustar nosso arquivo de configuração para os bancos. Você pode acompanhar as mudanças aqui

// config/database.yml
development:
  global: # Novo banco global que cuidará de Practice e Session
    <<: *default
    database: storage/development_global.sqlite3
  primary: # Banco que cuida de User, Staff e Appointment
    <<: *default
    tenanted: true # Aviso que esse banco será um Tenant
    database: storage/development/%{tenant}.sqlite3
    # Local para o storage mais o nome utilizado pelo Tenant
  patients: # Banco que continua cuidando apenas de Patient
    <<: *default
    tenanted: true
    database: storage/development/patients_%{tenant}.sqlite3
    migrations_paths: db/patients_migrate
  patients_replica: # Replica de Patient também como um Tenant
    <<: *default
    tenanted: true
    replica: true
    database: storage/development/patients_%{tenant}.sqlite3

E também precisamos atualizar cada Record:

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
  tenanted :primary # Removendo o antigo `connects_to database`

class GlobalRecord < ActiveRecord::Base
  # Conexão de escrita/leitura com o novo banco Global
  connects_to database: { writing: :global, reading: :global }

class PatientsRecord < ActiveRecord::Base
  tenanted :patients # Removendo o antigo `connects_to database`

A gema faz integração com as tasks padrões do Rails mas também disponibiliza algumas novas como:

- `db:migrate:tenant:all` - Migra todos os tenants existentes
- `db:migrate:tenant ARTENANT=name` - Migra um tenant específico
- `db:migrate:tenant` - Migrate default development tenant
- `db:tenant` - Define o tenant atual como ARTENANT, caso contrário, o ambiente padrão
- `db:reset:tenant` - Deleta e recriar todos os bancos de dados de tenants a partir de seus esquemas para o ambiente atual

Para autenticar um usuário precisamos saber qual Practice para defirnimos qual o Tenant que está ativo no momento. O resolvedor de Tenant padrão do ActiveRecord::Tenanted usa o subdomínio;

# config/initializers/active_record_tenanted.rb
Rails.application.configure do
  # Cada modelo herdado de ApplicationRecord é Tenanted
  config.active_record_tenanted.connection_class = "ApplicationRecord"
  # Subdomínio da request é usado para resolver o Tenant
  config.active_record_tenanted.tenant_resolver = ->(request) { request.subdomain }
end

Nesse momento após inúmeras soluções alternativas, percebi que pelo menos por ora não é possível mantermos múltiplos bancos como tenanted, o nosso primary e o patients. A gema foi projetada para uma única classe de conexão, então tive vários problemas pra tentar mudar as conexões sem cairmos no problema de Shard Swapping Conflicts, tanto para sobrescrever alguns comportamentos do middleware quanto definir o current_tenant do PatientsRecord em diferentes momentos (criação da sessão ou na chamada das rotas, que também estava só fugindo do que a gema nos disponibiliza por hora).

Para rodar as migrações para o banco de patients seria necessário criarmos um gerador diferente do que a gema nos fornece, pois ela também funciona só com a classe configurada. Fora que também na tentativa de gerar alguns seeds para testes locais com múltiplos Practices elas sempre apontavam para o ApplicationRecord mesmo eu forçando o contexto do Patients com algo como PatientsRecord.with_tenant(tenant_name)

Criei uma discussão Multi-Database Support Limitations no repositório da gema, vamos acompanhar os próximos passos.

Então aqui simplesmente removi a parte do database que faziam referência ao patients e a model volta para o chapéu de class Patient < ApplicationRecord.

Após rodar as migrações se tentarmos rodar os testes, ainda teremos vários problemas que precisamos arrumar. Não vou colocar passo a passo do código mas eles são:

Como movemos o Practice e Session para o banco global, temos que remover as restrições de chave-estrangeira relativas e validar a presença dos ids em cada model:

remove_foreign_key :staffs, :practices if foreign_key_exists?(:staffs, :practices)
remove_foreign_key :sessions, :users if foreign_key_exists?(:sessions, :users)
remove_foreign_key :patients, :practices if foreign_key_exists?(:patients, :practices)
remove_foreign_key :appointments, :practices if foreign_key_exists?(:appointments :practices)

Precisamos resolver subdomínios localmente (melhor solução que encontrei):

# config/environments/development.rb
config.action_dispatch.tld_length = 0

Adição desse slug para facilitar o encontro dos Practice e.g. clínica com nome “Development Clinic” gera um banco com /{env_name}/development-clinic.sqlite3;

Necessidade de adicionar alguns métodos para procurar algumas referências em outros bancos;

# models/practice.rb
def patients
  return Patient.none unless slug.present?
  ApplicationRecord.with_tenant(slug) { Patient.where(practice_id: id) }
end

# models/patient.rb
def practice
  return nil unless practice_id
  @practice ||= Practice.find_by(id: practice_id)
end

Na criação de um Practice temos que criar um Tenant para o banco Primary;

# models/practice.rb
# Tive vários problemas com esse setup em test, o melhor caminho foi desativar
after_create :setup_tenants, unless: -> { Rails.env.test? }
def setup_tenants
# ...
ApplicationRecord.create_tenant(slug)
# ...

Criei uma seed para facilitar os nossos testes em desenvolvimento e agora podemos brincar um pouco com o que temos:

  healthcare_management git:(tenanted)  rails c
Loading development environment (Rails 8.1.0.beta1)
# A migração já vai criar um banco padrão para dev que eu sobrescrevi o nome
Defaulting current tenant to "test-medical-center"

# Todos os tenants que temos disponíveis
3.4.5 :001 > ApplicationRecord.tenants
 => ["development-clinic", "metro-health-center", "sunset-medical-group", "test-medical-center"]
# Colocando o tenant atual para um com dados
3.4.5 :002 > ApplicationRecord.current_tenant = "development-clinic"
=> "development-clinic"
# Percebam que apenas mudando de tenant a gema cuida para buscar daquele banco
3.4.5 :003 > Patient.all
# Valida o se o database schema existe, sua estrutura e se está atualizada
  SCHEMA [tenant=development-clinic] (1.8ms)  SELECT sqlite_version(*) /*application='HealthcareManagement'*/
  SCHEMA [tenant=development-clinic] (3.5ms)  SELECT name FROM pragma_table_list WHERE schema <> 'temp' AND name NOT IN ('sqlite_sequence', 'sqlite_schema') AND name = 'schema_migrations' AND type IN ('table') /*application='HealthcareManagement'*/
  SCHEMA [tenant=development-clinic] (0.0ms)  SELECT name FROM pragma_table_list WHERE schema <> 'temp' AND name NOT IN ('sqlite_sequence', 'sqlite_schema') AND name = 'ar_internal_metadata' AND type IN ('table') /*application='HealthcareManagement'*/
  ActiveRecord::SchemaMigration Load [tenant=development-clinic] (0.1ms)  SELECT "schema_migrations"."version" FROM "schema_migrations" ORDER BY "schema_migrations"."version" ASC /*application='HealthcareManagement'*/

# Propriamente executa a query
  Patient Load [tenant=development-clinic] (0.2ms)  SELECT "patients".* FROM "patients" /* loading for pp */ LIMIT 11 /*application='HealthcareManagement'*/
 =>

#  Dados nos nossos Patients no contexto do Tenant development-clinic
[#<Patient id: 1, active: true, address: "101 Code Street, Dev City, ST 12345", blood_type: nil, created_at: "2025-09-17 21:29:02.824924000 +0000", date_of_birth: "1993-09-17", email: [FILTERED], emergency_contact_name: "Jamie Developer", emergency_contact_phone: "(555) 111-1112", first_name: "Alex", gender: nil, insurance_policy_number: nil, insurance_provider: nil, last_name: "Developer", phone: "(555) 111-1111", practice_id: 1, updated_at: "2025-09-17 21:29:02.824924000 +0000", tenant="development-clinic">,
 #<Patient id: 2, active: true, address: "102 Debug Ave, Dev City, ST 12345", blood_type: nil, created_at: "2025-09-17 21:29:02.829741000 +0000", date_of_birth: "1996-09-17", email: [FILTERED], emergency_contact_name: "Casey Tester", emergency_contact_phone: "(555) 111-2223", first_name: "Taylor", gender: nil, insurance_policy_number: nil, insurance_provider: nil, last_name: "Tester", phone: "(555) 111-2222", practice_id: 1, updated_at: "2025-09-17 21:29:02.829741000 +0000", tenant="development-clinic">]

# Trocando de tenant e fazendo a mesma busca
3.4.5 :004 > ApplicationRecord.current_tenant = "sunset-medical-group"
 => "sunset-medical-group"
3.4.5 :005 > Patient.all
...
 =>
# Como estamos usando ids sequenciais é fácil identificar que eles se repetem em cada banco o que é esperado mesmo, mas podemos identificar que os usuários não são os mesmos e também referenciam o practice_id diferente
[#<Patient id: 1, active: true, address: "900 Sunset Drive, West Hills, CA 90210", blood_type: nil, created_at: "2025-09-17 21:29:02.866274000 +0000", date_of_birth: "1994-09-17", email: [FILTERED], emergency_contact_name: "Sky Sunset", emergency_contact_phone: "(555) 345-1112", first_name: "Phoenix", gender: nil, insurance_policy_number: nil, insurance_provider: nil, last_name: "Sunset", phone: "(555) 345-1111", practice_id: 3, updated_at: "2025-09-17 21:29:02.866274000 +0000", tenant="sunset-medical-group">,
 #<Patient id: 2, active: true, address: "901 Pacific View, West Hills, CA 90210", blood_type: nil, created_at: "2025-09-17 21:29:02.868483000 +0000", date_of_birth: "1999-09-17", email: [FILTERED], emergency_contact_name: "Bay Coastal", emergency_contact_phone: "(555) 345-2223", first_name: "Ocean", gender: nil, insurance_policy_number: nil, insurance_provider: nil, last_name: "Coastal", phone: "(555) 345-2222", practice_id: 3, updated_at: "2025-09-17 21:29:02.868483000 +0000", tenant="sunset-medical-group">]

# Outro ponto importante é que como já foi explicado não conseguimos fazer joins entre o banco global para buscar um Practice de um Patient por exemplo, então precisamos da adição dos métodos para fazer essa busca como nos exemplos do ponto 3, que vai acarretar sempre em uma segunda query
3.4.5 :006 > Patient.first.practice.to_sql
  Patient Load [tenant=sunset-medical-group] (0.6ms)  SELECT "patients".* FROM "patients" ORDER BY "patients"."id" ASC LIMIT 1 /*application='HealthcareManagement'*/
  Practice Load (0.3ms)  SELECT "practices".* FROM "practices" WHERE "practices"."id" = 3 LIMIT 1 /*application='HealthcareManagement'*/

E caso quisermos testar direto na aplicação, como informado mais acima, o tenant_resolver usa do domínio para fazer a troca do contexto dos Tenant.

Pronto, e agora pra ilustrar ainda mais como tudo ficou:

ActiveRecord::Base
├── GlobalRecord (gravação/leitura de banco de dados global)
│   ├── Practice (gerencia os Tenants, cria/destroi novos Tenants databases)
│   └── Session (Sessões de Autenticação)
└── ApplicationRecord (banco tenanted :primary)
    ├── User 
    ├── Staff 
    ├── Appointment
    └── Patient

E por hoje é isso, para um próximo artigo vamos abordar as maiores da vantagens da ActiveRecord::Tenanted com caching, Action Cable, Action Job, Action Storage e por aí vai.

Referências:

Nos vemos no GitHub, até breve! 🚀