O que são query objects?
O model de 800 linhas que ninguém quer tocar
Você abre app/models/user.rb numa segunda-feira de manhã. Ele tem 847 linhas, com 34 scopes, 12 métodos de classe que montam consultas e um método self.dashboard_stats de 60 linhas de SQL cru costurado com interpolação de strings. Três pessoas deixaram # TODO: refactor this, e ninguém refatorou.
Você precisa de uma consulta nova: usuários que se matricularam nos últimos 30 dias, concluíram pelo menos uma lição e não entram há uma semana. Onde ela fica? No model User, no model Enrollment, num controller, num concern?
Toda opção parece errada porque a pergunta está errada. A pergunta melhor é por que a lógica de consulta mora no model.
O problema: models gordos
O Rails ensina a pôr lógica de consulta em scopes, e scopes são ótimos para filtros simples, de um propósito só:
class User < ApplicationRecord
scope :active, -> { where(active: true) }
scope :admins, -> { where(role: :admin) }
end
Mas bases de código em produção não continuam simples. Os requisitos crescem, as consultas se acumulam, e logo o model fica assim:
class User < ApplicationRecord
# 15 scopes...
scope :active, -> { where(active: true) }
scope :with_recent_activity, -> { where('last_login_at > ?', 30.days.ago) }
scope :enrolled_in, ->(course) { joins(:enrollments).where(enrollments: { course_id: course.id }) }
scope :completed_course, ->(course) { ... }
scope :with_xp_above, ->(threshold) { ... }
# Class methods que constroem queries complexas...
def self.leaderboard(period:)
joins(:experience_points)
.where(experience_points: { created_at: period })
.group(:id)
.select('users.*, SUM(experience_points.amount) as total_xp')
.order('total_xp DESC')
.limit(50)
end
def self.churn_risk_report
# 40 linhas de joins, subqueries e CTEs
end
def self.engagement_metrics(start_date:, end_date:)
# Outras 30 linhas
end
end
Esse model agora quebra o princípio da responsabilidade única. Ele define a entidade User, com validações e associações, e guarda lógica de consulta específica do negócio para rankings, relatórios de churn e métricas de engajamento. Essas preocupações mudam por motivos diferentes, em momentos diferentes, a pedido de pessoas diferentes.
O que muda, e quem pede
Pense em quem pede mudanças em cada parte:
+---------------------------+------------------+
| O que muda | Quem solicita |
+---------------------------+------------------+
| Validações do User | Time de segurança|
| Associações do User | Time de dados |
| Query do leaderboard | Product manager |
| Relatório de churn | Time de growth |
| Métricas de engajamento | Time de analytics|
+---------------------------+------------------+
Quando o time de crescimento muda o cálculo do risco de churn, você está editando o mesmo arquivo que o time de segurança edita para as validações de senha, rodando a mesma suíte de testes e criando conflitos de merge com trabalho que não tem nada a ver. O model virou um gargalo de coordenação.
O padrão query object
Um query object é uma classe Ruby simples com um trabalho: guardar uma única consulta, ou uma família de consultas bem relacionadas. Ele recebe parâmetros, monta uma consulta do Active Record e devolve os resultados.
Aqui está a lógica do ranking tirada do model gordo e posta numa classe própria:
# app/queries/enrollment/leaderboard_query.rb (um esboço simplificado; o real do app lê snapshots e tem outro formato)
module Enrollment
class LeaderboardQuery
Result = Data.define(:entries)
def self.call(period: 30.days.ago..Time.current, limit: 50)
entries = Identity::User
.joins(:experience_points)
.where(experience_points: { created_at: period })
.group('users.id')
.select('users.*, SUM(experience_points.amount) AS total_xp')
.order('total_xp DESC')
.limit(limit)
Result.new(entries: entries)
end
end
end
E aqui está como um controller a chama:
# Num controller
result = Enrollment::LeaderboardQuery.call(period: 7.days.ago..)
@leaderboard = result.entries
O model fica limpo de novo. A consulta mora no próprio arquivo, no namespace do domínio, com os próprios testes. Quando o gerente de produto muda o ranking, você abre um arquivo, sem conflitos de merge com as validações de usuário e sem risco para a autenticação. A tabela experience_points deste app não tem coluna source_id, e os valores de source_type são nomes de atividade como "lesson_completion", então o exemplo não filtra por curso.
Este app também tem um Enrollment::LeaderboardQuery real, com o mesmo nome do esboço acima e outro formato. Ele recebe o usuário que está vendo e um período, lê o snapshot atual do ranking e devolve o top 50 mais os vizinhos de quem está vendo. O ranking em si, uma window function do SQL (RANK() OVER (ORDER BY xp DESC)), roda num job em background a cada 15 minutos, para que o banco faça a contagem em vez de carregar todos os usuários.
Antes e depois
ANTES (model gordo) DEPOIS (query objects)
+------------------------+ +------------------------+
| User Model (847 linhas)| | User Model (45 linhas) |
| | | validações |
| validações | | associações |
| associações | | scopes (simples) |
| 34 scopes | +------------------------+
| query do leaderboard |
| relatório de churn | +------------------------+
| métricas engajamento | | LeaderboardQuery |
| stats do dashboard | | uma classe, um job |
| ... | +------------------------+
+------------------------+
+------------------------+
| ChurnRiskQuery |
| uma classe, um job |
+------------------------+
+------------------------+
| EngagementMetricsQuery |
| uma classe, um job |
+------------------------+
Cada query object tem de 20 a 50 linhas, pode ser testado sozinho e fica no namespace de domínio a que pertence.
O contrato
Nesta arquitetura, query objects seguem um contrato:
self.callé a única porta de entrada, semnewe sem estado de instância.- Eles devolvem um
Result = Data.define(...), um valor estruturado e imutável. - São só de leitura: nada de
create!,update!oudestroy!. - Moram em
app/queries/<domain>/, com namespace por domínio. - Devolvem relations ou objetos Data, nunca hashes crus nem arrays de hashes.
Este app segue o contrato quase em toda parte. Quase todo arquivo em app/queries/ define self.call, e nenhum deles grava. O Enrollment::LeaderboardQuery real vale a leitura: self.call(viewer:, period:) devolve um Result montado com Data.define, e ele só lê.
O contrato torna os query objects previsíveis. Todo mundo no time sabe que uma classe em app/queries/ é só de leitura, devolve um Result e é chamada com .call.
Quando scopes bastam
Query objects não substituem scopes; eles se complementam:
| Use um scope quando... | Use um query object quando... |
|---|---|
| O filtro é simples, com uma ou duas condições | A consulta envolve várias tabelas |
| Ele faz parte da identidade do model | Ele atende a um caso de uso do negócio |
| Ele é reutilizado em muitos lugares | Ele é usado por um controller ou serviço |
| Ele devolve o mesmo model | Ele agrega, agrupa ou transforma dados |
Exemplo: User.active |
Exemplo: um ranking com posições por XP |
Scopes descrevem o que um model é, como usuários ativos ou cursos publicados. Query objects descrevem o que o negócio precisa, como rankings, relatórios de churn e painéis de análise.
Erros comuns
O primeiro erro é pôr lógica de consulta em controllers:
# Ruim — controller fazendo trabalho de query
def index
@users = Identity::User
.joins(:enrollments, :experience_points)
.where(enrollments: { status: :active })
.group('users.id')
.select('users.*, SUM(amount) as xp')
.order('xp DESC')
end
Essa consulta não pode ser reutilizada nem testada sozinha, e incha o controller. Extraia-a para um query object.
O segundo é um query object que devolve hashes em vez de relations ou objetos Result:
# Ruim — retorna hash cru, quebra encadeamento
def self.call
ActiveRecord::Base.connection.execute(sql).to_a
end
Hashes crus perdem os tipos, não aceitam mais filtros e não seguem a convenção de Result do projeto.
O terceiro é criar um query object para User.active. Filtros simples pertencem ao model, como scopes. Query objects são para consultas complexas e específicas do negócio que não pertencem ao model.
Como isso se encaixa no Rails
O Rails não tem uma camada pronta de query objects, mas o desenho dele favorece o padrão. Relations do Active Record são preguiçosas: não vão ao banco até você iterar. Então um query object pode montar uma relation, devolvê-la e deixar quem chama decidir quando rodá-la, depois da paginação, de filtros extras ou da renderização.
O Zeitwerk carrega app/queries/ automaticamente, como app/models/ e app/services/, sem configuração. Ponha um arquivo, siga a convenção de nomes, e ele fica disponível em toda parte.
Nesta arquitetura DDD, query objects ficam ao lado dos service objects como os dois pilares da camada de domínio. Serviços cuidam das gravações, consultas cuidam das leituras, e controllers orquestram os dois.
Resumo
| Conceito | Ponto-chave |
|---|---|
| Models gordos | Models acumulam lógica de consulta até virarem gargalos de coordenação |
| Responsabilidade única | Um model define a entidade; a lógica de consulta fica em outro lugar |
| Query object | Uma classe Ruby simples que guarda uma consulta, chamada com .call, devolvendo um Result |
| Contrato | Só de leitura, devolve Data.define(...), mora em app/queries/<domain>/ |
| Scopes vs query objects | Scopes descrevem o que um model é; query objects descrevem o que o negócio precisa |
| Lugar no DDD | Consultas cuidam das leituras, serviços das gravações, controllers orquestram os dois |
| Integração com o Rails | Relations preguiçosas e o carregamento do Zeitwerk tornam os query objects fáceis de adotar |
Continue com a aula 2
Construindo seu primeiro query object
Monta um query object com self.call, Result via Data.define, argumentos nomeados, filtros condicionais e relations preguiçosas.
Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.