# Rails desde cero (III): ActiveRecord avanzado

*Tercera entrega de la serie **[Rails desde cero](/search?tag=rails-desde-cero)**. Tiempo de lectura estimado: 15 minutos.*

Si ya tienes claro cómo funcionan los modelos, las validaciones y las asociaciones básicas, es hora de hablar de lo que realmente separa una aplicación Rails mantenible de una que se convierte en un problema. En este artículo cubrimos los temas que más impactan en el rendimiento, la legibilidad y la escalabilidad: el problema N+1, eager loading, consultas avanzadas, el uso correcto de callbacks, y cómo estructurar la lógica de negocio cuando los modelos empiezan a crecer.

## El problema N+1: el error más común en Rails

![Rails desde cero (III): ActiveRecord avanzado](fig-01.webp)

El problema N+1 es probablemente el bug de rendimiento más frecuente en aplicaciones Rails, y lo más traicionero es que no se ve en el código: se ve en los logs.

Imagina este controlador y esta vista:

```ruby
# app/controllers/articles_controller.rb
def index
  @articles = Article.published.recent
end
```

```ruby
<!-- app/views/articles/index.html.erb -->
<% @articles.each do |article| %>
  <p><%= article.title %> — por <%= article.user.name %></p>
<% end %>
```

A simple vista parece correcto. Pero en los logs verás algo así:

```ruby
Article Load (1.2ms)  SELECT "articles".* FROM "articles" WHERE ...
User Load (0.4ms)     SELECT "users".* FROM "users" WHERE "users"."id" = 1
User Load (0.3ms)     SELECT "users".* FROM "users" WHERE "users"."id" = 2
User Load (0.4ms)     SELECT "users".* FROM "users" WHERE "users"."id" = 1
User Load (0.3ms)     SELECT "users".* FROM "users" WHERE "users"."id" = 3
...
```

Una consulta para cargar los artículos, y luego **una consulta por cada artículo** para cargar su usuario. Si tienes 100 artículos, son 101 consultas. Si tienes 1.000, son 1.001. Esto es el problema N+1.

### La solución: `includes`

```ruby
@articles = Article.published.recent.includes(:user)
```

Con `includes`, ActiveRecord carga todos los usuarios necesarios en **una sola consulta adicional**:

```ruby
Article Load (1.2ms)  SELECT "articles".* FROM "articles" WHERE ...
User Load (0.8ms)     SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...)
```

De N+1 consultas a 2. La diferencia en rendimiento puede ser de órdenes de magnitud.

### `includes` vs `eager_load` vs `preload`

Rails tiene tres métodos para cargar asociaciones de forma anticipada, y no son intercambiables:

**`preload`** siempre usa dos consultas separadas, independientemente de si necesitas filtrar por la asociación:

```ruby
Article.preload(:user)
# SELECT * FROM articles
# SELECT * FROM users WHERE id IN (...)
```

**`eager_load`** hace un `LEFT OUTER JOIN` en una sola consulta. Es necesario cuando quieres filtrar u ordenar por campos de la asociación:

```ruby
Article.eager_load(:user).where(users: { role: "admin" })
# SELECT articles.*, users.* FROM articles
# LEFT OUTER JOIN users ON users.id = articles.user_id
# WHERE users.role = 'admin'
```

**`includes`** decide automáticamente entre los dos anteriores según el contexto. En la mayoría de casos es la opción correcta.

```ruby
# Usa preload (dos consultas)
Article.includes(:user)

# Detecta que necesita JOIN y usa eager_load automáticamente
Article.includes(:user).where(users: { role: "admin" })
```

### Detectar N+1 en desarrollo

La gema [Bullet](https://github.com/flyerhzm/bullet) detecta automáticamente problemas N+1 y te avisa en el log o con alertas en el navegador. Es casi obligatoria en cualquier proyecto Rails serio:

```ruby
# Gemfile
gem "bullet", group: :development

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.rails_logger = true
  Bullet.add_footer = true
end
```

## Consultas avanzadas

### Joins

Cuando necesitas consultar datos de tablas relacionadas sin cargar los objetos asociados en memoria:

```ruby
# Artículos que tienen al menos un comentario
Article.joins(:comments).distinct

# Artículos de usuarios con rol admin
Article.joins(:user).where(users: { role: "admin" })

# Varios joins encadenados
Article.joins(:user, :tags).where(tags: { name: "rails" })
```

La diferencia clave entre `joins` e `includes`: `joins` hace el JOIN para filtrar, pero **no carga los objetos asociados en memoria**. Si luego accedes a `article.user` dentro de un bucle, vuelves al problema N+1. `includes` sí carga los objetos.

En la práctica: usa `joins` para filtrar, usa `includes` para mostrar datos asociados, y combínalos cuando necesitas ambas cosas:

```ruby
Article.joins(:user)
       .includes(:tags)
       .where(users: { role: "admin" })
       .order("users.name")
       ```

### Seleccionar columnas específicas

Cuando manejas tablas con muchas columnas o campos grandes, cargar solo lo que necesitas mejora el rendimiento:

```ruby
Article.select(:id, :title, :published_at)
```

Cuidado: los atributos que no hayas seleccionado lanzarán un `ActiveModel::MissingAttributeError` si intentas acceder a ellos.

### SQL arbitrario con `Arel` y literales

Para consultas que la API de ActiveRecord no puede expresar directamente:

```ruby
# Literal SQL donde sea necesario
Article.where("published_at > NOW() - INTERVAL '7 days'")

# Con Arel para algo más portable
Article.where(Article.arel_table[:published_at].gt(7.days.ago))

# Subconsultas
popular_tag_ids = Tag.where("articles_count > ?", 100).select(:id)
Article.joins(:tags).where(tags: { id: popular_tag_ids })
```

### Agrupación y agregados

```ruby
# Artículos publicados por mes
Article.published
       .group("DATE_TRUNC('month', published_at)")
       .count

# Media de comentarios por artículo
Article.joins(:comments)
       .group(:id)
       .average("comments.count")

# Los 5 autores con más artículos publicados
User.joins(:articles)
    .where(articles: { published: true })
    .group(:id)
    .order("COUNT(articles.id) DESC")
    .limit(5)
    .select("users.*, COUNT(articles.id) AS articles_count")
```

### `find_each` y `find_in_batches` para grandes volúmenes

`Article.all` carga todos los registros en memoria de una vez. Con tablas grandes, esto puede consumir gigabytes de RAM. La solución es procesar en lotes:

```ruby
# Procesa los registros de 1000 en 1000 (por defecto)
Article.find_each do |article|
  article.regenerate_slug!
end

# Con tamaño de lote personalizado
Article.published.find_each(batch_size: 500) do |article|
  ArticleIndexJob.perform_later(article.id)
end

# Si necesitas el lote completo como array
Article.find_in_batches(batch_size: 200) do |articles|
  ElasticsearchClient.bulk_index(articles)
end
```

## Callbacks: cuándo usarlos y cuándo no

![Rails desde cero (III): ActiveRecord avanzado](fig-02.webp)

Los callbacks son tentadores porque hacen que el modelo sea «inteligente». Pero un modelo con demasiados callbacks se convierte en una caja negra: es difícil saber qué va a pasar cuando llamas a `save`, difícil de testear y difícil de razonar.

### Cuándo los callbacks son apropiados

Los callbacks son adecuados para lógica que siempre debe ocurrir junto con el cambio de estado del registro, sin excepciones:

```ruby
class Article < ApplicationRecord
  before_save :generate_slug
  before_save :set_word_count

  private

  def generate_slug
    self.slug = title.parameterize if title_changed?
  end

  def set_word_count
    self.word_count = body.to_s.split.length
  end
end
```

Calcular un slug o un contador de palabras a partir de atributos del propio modelo es un uso perfecto: es pura lógica interna que siempre debe mantenerse consistente.

### Cuándo los callbacks son un problema

El problema aparece cuando los callbacks tienen efectos secundarios en el exterior: enviar emails, encolar jobs, modificar otros modelos, hacer llamadas HTTP…

```ruby
# ❌ Problemático
class Article < ApplicationRecord
  after_create :send_notification_email
  after_create :notify_external_api
  after_destroy :update_author_stats

  private

  def send_notification_email
    ArticleMailer.published(self).deliver_later
  end
end
```

El problema es que ahora no puedes crear un artículo en tests sin que se intente enviar un email. No puedes crear artículos de prueba en seeds sin efectos secundarios. Y si el job falla, ¿sabes que fue porque se lanzó desde un callback?

La alternativa es ser explícito en el lugar donde ocurre la acción de negocio:

```ruby
# ✅ Explícito en el servicio o controlador
class ArticlesController < ApplicationController
  def create
    @article = Article.new(article_params)

    if @article.save
      ArticleMailer.published(@article).deliver_later
      ArticlePublishedJob.perform_later(@article.id)
      redirect_to @article
    else
      render :new, status: :unprocessable_entity
    end
  end
end
```

### `after_commit` vs `after_save`

Si realmente necesitas un callback con efectos secundarios, usa `after_commit` en lugar de `after_save`. La razón es que `after_save` se ejecuta dentro de la transacción, y si algo falla después, la transacción se revierte pero el efecto secundario (el email, el job) ya se habrá lanzado.

```ruby
# ❌ Se ejecuta dentro de la transacción
after_save :enqueue_index_job

# ✅ Se ejecuta solo cuando la transacción se confirma
after_commit :enqueue_index_job, on: :create
```

## Mantener los modelos limpios

A medida que una aplicación crece, los modelos tienden a acumular lógica de negocio hasta convertirse en «fat models»: cientos de líneas, decenas de métodos, callbacks entrelazados. La comunidad Rails ha desarrollado varios patrones para combatir esto.

### Concerns

Los concerns son módulos que encapsulan un conjunto de comportamientos reutilizables. Rails los carga automáticamente desde `app/models/concerns/`:

```ruby
# app/models/concerns/publishable.rb
module Publishable
  extend ActiveSupport::Concern

  included do
    scope :published, -> { where(published: true) }
    scope :unpublished, -> { where(published: false) }

    validates :published_at, presence: true, if: :published?
  end

  def publish!
    update!(published: true, published_at: Time.current)
  end

  def unpublish!
    update!(published: false, published_at: nil)
  end

  def published?
    published? && published_at.present?
  end
end

# app/models/article.rb
class Article < ApplicationRecord
  include Publishable
end
```

Los concerns son útiles para comportamientos genuinamente reutilizables entre modelos (como `Publishable`, `Searchable` o `Auditable`). No son una solución para simplemente mover código a otro archivo: si el concern solo lo usa un modelo, probablemente ese código debería estar en el modelo directamente.

### Objetos de servicio

Para lógica de negocio compleja que involucra múltiples modelos o efectos secundarios, los objetos de servicio son una opción muy popular:

```ruby
# app/services/article_publisher.rb
class ArticlePublisher
  def initialize(article, publisher:)
    @article = article
    @publisher = publisher
  end

  def call
    return false unless @publisher.can_publish?(@article)

    ActiveRecord::Base.transaction do
      @article.publish!
      @publisher.increment!(:published_articles_count)
      AuditLog.create!(action: "article_published", actor: @publisher, target: @article)
    end

    ArticleMailer.published(@article).deliver_later
    true
  rescue ActiveRecord::RecordInvalid => e
    @article.errors.add(:base, e.message)
    false
  end
end

# En el controlador
def publish
  result = ArticlePublisher.new(@article, publisher: current_user).call

  if result
    redirect_to @article, notice: "Artículo publicado."
  else
    render :edit, status: :unprocessable_entity
  end
end
```

El servicio encapsula toda la operación: la validación de permisos, la transacción, el log de auditoría y el envío del email. El modelo y el controlador quedan limpios.

### Query Objects

Para consultas complejas que no encajan bien en un scope, los Query Objects son una alternativa limpia:

```ruby
# app/queries/popular_articles_query.rb
class PopularArticlesQuery
  def initialize(relation = Article.all)
    @relation = relation
  end

  def call(limit: 10, since: 1.month.ago)
    @relation
      .published
      .joins(:comments)
      .where("articles.published_at > ?", since)
      .group("articles.id")
      .order("COUNT(comments.id) DESC")
      .limit(limit)
      .select("articles.*, COUNT(comments.id) AS comments_count")
  end
end

# Uso
PopularArticlesQuery.new.call(limit: 5)
PopularArticlesQuery.new(Article.by_author(current_user)).call
```

## Transacciones

![Rails desde cero (III): ActiveRecord avanzado](fig-03.webp)

Cuando necesitas que varias operaciones en base de datos ocurran juntas o no ocurran en absoluto, usa una transacción explícita:

```ruby
ActiveRecord::Base.transaction do
  order = Order.create!(user: current_user, total: cart.total)
  cart.items.each do |item|
    order.line_items.create!(product: item.product, quantity: item.quantity)
    item.product.decrement!(:stock, item.quantity)
  end
  cart.destroy!
end
```

Si cualquier operación dentro del bloque lanza una excepción, toda la transacción se revierte. Importante: solo las excepciones hacen rollback, los `false` no. Por eso dentro de transacciones deberías usar siempre los métodos con `!`.

Si necesitas revertir manualmente desde dentro de la transacción:

```ruby
ActiveRecord::Base.transaction do
  do_something
  raise ActiveRecord::Rollback if some_condition
  do_something_else
end
```

`ActiveRecord::Rollback` es especial: hace rollback pero no propaga la excepción fuera del bloque.

## Locking: concurrencia y condiciones de carrera

Cuando múltiples procesos pueden modificar el mismo registro simultáneamente, necesitas alguna estrategia de locking.

### Optimistic locking

Ideal cuando los conflictos son poco frecuentes. Rails lo soporta de forma nativa con una columna `lock_version`:

```ruby
# En la migración
add_column :articles, :lock_version, :integer, default: 0

# ActiveRecord lo gestiona solo:
article_a = Article.find(1)
article_b = Article.find(1)  # misma versión

article_a.update!(title: "Versión A")  # ok, lock_version pasa a 1
article_b.update!(title: "Versión B")  # lanza ActiveRecord::StaleObjectError
```

### Pessimistic locking

Para operaciones críticas donde no puedes permitir conflictos:

```ruby
Article.transaction do
  article = Article.lock.find(1)  # SELECT ... FOR UPDATE
  article.increment!(:views_count)
end
```

`lock` bloquea la fila a nivel de base de datos hasta que la transacción termina. Úsalo con cuidado y en transacciones cortas para evitar deadlocks.

## Algunos patrones útiles

Para cerrar el artículo, algunos métodos y patrones que aparecen constantemente en código Rails maduro:

```ruby
# find_or_create_by: busca o crea atómicamente
tag = Tag.find_or_create_by(name: "ruby")

# upsert y upsert_all: insert or update eficiente sin pasar por Ruby
Article.upsert({ id: 1, title: "Actualizado", updated_at: Time.current })

# upsert_all para operaciones masivas
Article.upsert_all(articles_data, unique_by: :slug)

# update_all: actualiza sin cargar objetos en memoria
Article.where(status: "pending").update_all(status: "draft", updated_at: Time.current)

# delete_all: elimina sin cargar ni ejecutar callbacks
Article.where("created_at < ?", 1.year.ago).delete_all

# touch: actualiza solo el timestamp
article.touch  # updated_at = now
article.touch(:published_at)  # campo específico

# Dirty tracking: saber qué cambió antes de guardar
article.title = "Nuevo título"
article.title_changed?   # => true
article.title_was        # => "Título anterior"
article.changes          # => { "title" => ["Título anterior", "Nuevo título"] }
```

## Resumen

ActiveRecord es una herramienta extremadamente poderosa, pero como cualquier herramienta poderosa, hay que saber dónde aprieta:

 - **N+1**: el problema más común y el más fácil de evitar con `includes`. Instala Bullet y no lo ignores.
 - **Joins vs includes**: distintos propósitos, a menudo complementarios.
 - **Callbacks**: úsalos para lógica interna del modelo, no para efectos secundarios.
 - **`after_commit`** en lugar de `after_save` cuando los efectos secundarios son inevitables.
 - **Modelos limpios**: concerns para comportamientos reutilizables, servicios para orquestar operaciones complejas, Query Objects para consultas sofisticadas.
 - **Transacciones y locking** cuando la consistencia de datos no es negociable.

Con estos patrones, la diferencia entre una aplicación que aguanta el crecimiento y una que se vuelve un problema empieza a ser visible muy pronto.

En el próximo artículo hablaremos de **testing en Rails**: cómo testear modelos, controladores y servicios, y cómo estructurar una suite de tests que sea útil y no un lastre.

*¿Has visto alguno de estos patrones en tu código? ¿Tienes dudas sobre cuándo aplicar cada uno? Los comentarios son tuyos.*
