---
name: rails-experto
description: >
  Ruby on Rails 7+ con Active Record, Hotwire (Turbo Frames/Streams), Action Cable,
  Sidekiq y RSpec. Patrones idiomáticos de Rails: convención sobre configuración,
  fat models/thin controllers, service objects. Cargar cuando se implemente
  cualquier componente Rails: modelos, controladores, jobs, WebSockets o al
  migrar a Rails 7+ con Hotwire desde vistas tradicionales.
version: "1.0.0"
herramientasPermitidas: [Read]
exclusiones:
  - "No cargar para escribir specs RSpec o configurar FactoryBot — los patrones de testing Rails están en el recurso `recursos/testing-rspec.md` de este mismo skill; para testing standalone cargar el recurso."
  - "No cargar para optimizaciones de base de datos SQL fuera de Active Record — para queries SQL avanzadas y plan de ejecución cargar `sql-optimizacion`."
  - "No cargar para APIs Django, FastAPI o Laravel — Rails tiene convenciones y naming completamente distintos."
  - "No cargar para errores de compilación Ruby o bundler — para errores de build Ruby cargar el skill de errores correspondiente."
evolvable: true  # default para skill estandar
---
# Rails Experto

## Cuándo NO cargar

- La pregunta es específicamente sobre escribir specs RSpec o configurar FactoryBot — el recurso `recursos/testing-rspec.md` de este skill cubre ese tema; para testing de otro framework cargar su skill.
- La pregunta es sobre optimizaciones SQL fuera de Active Record (índices, EXPLAIN, partitioning) — cargar `sql-optimizacion`.
- El framework es Django, FastAPI o Laravel — Rails tiene convenciones (naming, estructura, ORM) completamente distintas.
- Los errores son de bundler o gemas Ruby — diagnosticar con el skill de build errors de Ruby.

## Cuándo cargar este skill

- Implementar modelos Active Record con validaciones, scopes o asociaciones
- Crear controladores Rails y sus actions CRUD
- Construir service objects para lógica de negocio compleja
- Integrar Hotwire (Turbo Frames, Turbo Streams, Stimulus)
- Configurar Action Cable o canales de WebSocket
- Crear jobs con ActiveJob o Sidekiq
- Escribir tests con RSpec (modelos, requests, system specs)
- Migrar vistas ERB tradicionales a Hotwire
- Optimizar queries con eager loading para prevenir N+1
- Configurar ActiveRecord::Encryption para campos sensibles (Rails 7+)

## Estructura de proyecto Rails 7+

```
app/
├── controllers/
│   ├── application_controller.rb
│   └── api/v1/
├── models/
│   ├── concerns/         # Módulos compartidos entre modelos
│   └── application_record.rb
├── services/             # Service objects para lógica compleja
├── jobs/                 # ActiveJob / Sidekiq
├── views/
│   └── components/       # ViewComponent
├── javascript/
│   ├── controllers/      # Stimulus controllers
│   └── application.js
└── channels/             # Action Cable
```

## Regla crítica: prevenir N+1 con includes

```ruby
# NUNCA — N+1 query (1 + N queries en la vista)
@posts = Post.all
# En la vista: post.author.nombre  → query por cada post

# SIEMPRE — eager loading
@posts = Post.includes(:author, :tags).order(created_at: :desc)

# Con condición en asociación (usa JOIN, no subquery)
@posts = Post.eager_load(:author).where(authors: { verified: true })

# Bullet gem para detectar N+1 en desarrollo
# config/environments/development.rb:
# config.after_initialize { Bullet.enable = true; Bullet.rails_logger = true }
```

## Model con validaciones, scopes y asociaciones

```ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy
  has_many_attached :images
  has_rich_text :body

  enum :status, { borrador: 0, publicado: 1, archivado: 2 }, prefix: true

  validates :titulo, presence: true, length: { maximum: 200 }
  validates :cuerpo, presence: true

  scope :publicados, -> { where(status: :publicado) }
  scope :recientes,  -> { order(created_at: :desc) }
  scope :del_autor,  ->(user) { where(author: user) }

  before_save :normalizar_titulo

  private

  def normalizar_titulo
    self.titulo = titulo.strip.capitalize
  end
end
```

## Controller delgado con service objects

```ruby
class PostsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  def create
    result = Posts::CrearService.call(
      autor: current_user,
      parametros: post_params
    )

    if result.exitoso?
      redirect_to result.post, notice: 'Publicación creada'
    else
      @post = result.post
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def post_params
    params.require(:post).permit(:titulo, :cuerpo, :status, images: [])
  end
end
```

## Service Object pattern

```ruby
# app/services/posts/crear_service.rb
module Posts
  class CrearService
    Result = Struct.new(:exitoso?, :post, :errores, keyword_init: true)

    def self.call(autor:, parametros:)
      new(autor: autor, parametros: parametros).call
    end

    def initialize(autor:, parametros:)
      @autor      = autor
      @parametros = parametros
    end

    def call
      post = Post.new(@parametros.merge(author: @autor))

      if post.save
        NotificarSeguidoresJob.perform_later(post.id)
        Result.new(exitoso?: true, post: post, errores: [])
      else
        Result.new(exitoso?: false, post: post, errores: post.errors.full_messages)
      end
    end
  end
end
```

## Sidekiq Job

```ruby
class NotificarSeguidoresJob < ApplicationJob
  queue_as :default
  sidekiq_options retry: 3, dead: false, backtrace: 5

  def perform(post_id)
    post = Post.find_by(id: post_id)
    return unless post  # El post puede haberse eliminado

    post.author.seguidores.find_each do |seguidor|
      NotificacionMailer.nueva_publicacion(seguidor, post).deliver_later
    end
  rescue StandardError => e
    Rails.logger.error("NotificarSeguidoresJob falló para post #{post_id}: #{e.message}")
    raise  # Re-raise para que Sidekiq reintente
  end
end
```

## MUST DO / MUST NOT DO

**MUST DO:**
- `includes`/`eager_load` para prevenir N+1 en toda relación accedida fuera del modelo
- `strong_parameters` en todos los controllers (`permit` explícito, nunca `permit!`)
- Service objects para lógica de negocio compleja (más de 3 pasos o con side effects)
- `find_by` en lugar de `find` cuando el registro puede no existir
- Migraciones reversibles (`change` cuando es reversible, `up`/`down` cuando no)
- Tests con RSpec — cobertura >90% en modelos y services
- `perform_later` para jobs (async). `perform_now` solo en tests o CLIs

**MUST NOT DO:**
- Lógica de negocio en controllers ni en views
- `User.all` sin paginación en listas grandes (usar `kaminari` o `pagy`)
- Queries N+1 — instalar `bullet` gem en desarrollo para detectarlas
- `rescue Exception` — usar `rescue StandardError`
- `update_attribute` (omite validaciones) — usar `update` o `save`
- Secrets en código fuente — usar `rails credentials:edit` o variables de entorno
- Requests HTTP ni IO en callbacks de modelo (`after_save`, `after_create`)

## Checklist de verificación

- [ ] Toda relación accedida en vista o serialización usa `includes`/`eager_load`
- [ ] `post_params` usa `permit` explícito (sin `permit!`)
- [ ] Lógica compleja extraída a service object en `app/services/`
- [ ] Jobs reintentables, idempotentes y con `find_by` (no `find`)
- [ ] Migraciones con `change` o pareja `up`/`down`
- [ ] Tests de modelo, request y (si hay JS) system specs cubren el happy path y errores
- [ ] Sin secrets hardcodeados (usar `Rails.application.credentials` o `ENV`)

## Gotchas / Errores comunes no obvios

**Callbacks de modelo (`after_save`, `after_create`) con llamadas HTTP o IO bloquean la transacción completa**: Rails ejecuta los callbacks dentro de la transacción del `save`. Si el callback llama a un servicio externo (Stripe, Slack, S3) y falla, la transacción hace rollback pero el efecto externo ya ocurrió — estado inconsistente. Causa: los callbacks son síncronos y viven dentro del bloque de transacción de ActiveRecord. Fix: mover efectos secundarios externos a jobs (`after_create_commit { MyJob.perform_later(id) }`) o usar el patrón service object donde el commit ocurre antes de los side effects.

**`counter_cache` con `increment_counter` directo no dispara validaciones ni callbacks**: `Post.increment_counter(:comments_count, post_id)` actualiza la columna directamente con SQL sin pasar por el modelo, ignorando `before_save`, `after_save` y validaciones. Causa: es una operación SQL cruda para eficiencia. Fix: si se necesitan callbacks, usar `update` en la instancia del modelo; si solo se necesita el conteo sin callbacks, `increment_counter` es correcto y más eficiente.

**Migraciones con `add_index` en tablas grandes bloquean escrituras en PostgreSQL**: `add_index :facturas, :cliente_id` adquiere un `AccessExclusiveLock` durante toda la creación del índice — en tablas con millones de filas esto puede durar minutos bloqueando inserts y updates. Causa: el comportamiento por defecto de PostgreSQL. Fix: usar `add_index :facturas, :cliente_id, algorithm: :concurrently` en la migración, y separar la migración en su propio deployment sin otras operaciones DDL. Requiere que la migración sea irreversible o usar `disable_ddl_transaction!`.

**Turbo Frames requieren IDs de DOM únicos en toda la página — duplicados causan actualizaciones incorrectas**: si dos `turbo_frame_tag "comentario"` con el mismo id existen en la página (ej: en un listado), Turbo actualiza solo el primero que encuentra al recibir la respuesta. Causa: Turbo usa `document.getElementById()` que retorna el primer match. Fix: usar IDs compuestos con el `id` del registro: `turbo_frame_tag dom_id(comentario)` que genera `comentario_42`, `comentario_43`, etc.

## Referencias a recursos extendidos

| Tema | Archivo |
|------|---------|
| Active Record avanzado: concerns, counter_cache, STI, bulk ops, encryption | [recursos/active-record.md](recursos/active-record.md) |
| Hotwire: Turbo Frames, Turbo Streams, Stimulus, Action Cable, morphing | [recursos/hotwire-turbo.md](recursos/hotwire-turbo.md) |
| RSpec: factories, request specs, system specs, mocking, coverage | [recursos/testing-rspec.md](recursos/testing-rspec.md) |
