Javier Valencia Javier Valencia
Logo del elefante de PostgreSQL

Rails sostenible (VI): modelos y base de datos

Javier Valencia · · 6 min de lectura · 2 visitas · Desarrollo
rails ruby rails-sostenible bases-de-datos postgresql active-record

Sexta entrega de la serie Rails sostenible, sobre el libro de David Bryant Copeland. Tiempo de lectura estimado: 14 minutos.

Llegamos al sótano del sistema, y para mí es la parte más jugosa. Después de haber sacado la lógica de negocio de los modelos, toca preguntarse: entonces, ¿para qué sirve un Active Record? Y la respuesta nos lleva directos a la base de datos, esa capa que mucha gente trata como un detalle de implementación y que Copeland defiende como la última línea de defensa de la integridad de tus datos.

Active Record es para acceder a la base de datos

La primera distinción del libro es tajante. Un Active Record (User, Order...) es, ante todo, una pasarela a una tabla. Su trabajo es leer y escribir filas. No es el cerebro del negocio —eso vive en el seam— ni un cajón de utilidades. Cuando aceptas esto, los modelos adelgazan de forma natural: asociaciones, alguna consulta sencilla, poco más.

Active Model es para modelar recursos

¿Y qué pasa con los objetos que necesitas modelar pero que no son tablas? Un formulario de búsqueda, un formulario de contacto, un objeto que representa varios datos juntos para una vista. Para eso está Active Model, que te da el comportamiento de un modelo Rails (validaciones, conversiones de tipo, integración con los form helpers) sin tabla detrás:

# app/models/contact_form.rb
class ContactForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :name, :string
  attribute :email, :string
  attribute :message, :string

  validates :name, :email, :message, presence: true
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
end

Ahora ContactForm se comporta como cualquier modelo en tus vistas y controladores (form_with model: @contact_form), valida sus datos y devuelve mensajes de error amables, pero no toca la base de datos. Esta distinción —Active Record para persistencia, Active Model para modelado— evita el reflejo de crear tablas para cosas que no necesitan persistirse.

Modelo lógico y modelo físico

El capítulo de base de datos arranca con una distinción que viene del diseño de datos clásico y que Copeland reivindica:

  • Modelo lógico: el mapa conceptual. Qué entidades existen (Cliente, Pedido, Producto), cómo se relacionan, qué reglas las gobiernan. Es independiente de la tecnología y sirve para construir consenso con el negocio. Lo dibujas con la gente que entiende el dominio, no con la que entiende SQL.
  • Modelo físico: la implementación real. Tablas, columnas, tipos, índices y constraints. Aquí el objetivo no es el consenso, es la corrección: que la base de datos haga imposible guardar datos inválidos.

Separar ambos evita dos errores opuestos: discutir tipos de columna con un stakeholder de negocio, o diseñar tablas sin haber entendido el dominio.

Centro de datos

El modelo físico existe para imponer corrección

Esta es la tesis central del capítulo, y va a contracorriente de cómo mucha gente usa Rails. La base de datos no es un almacén tonto donde Rails vuelca objetos: es un motor de integridad. Copeland pide que el esquema físico imponga, a nivel de base de datos, todo lo que deba ser cierto siempre:

  • NOT NULL en toda columna que no pueda faltar.
  • Índices UNIQUE para la unicidad real.
  • Claves foráneas (foreign key) para la integridad referencial.
  • CHECK constraints para invariantes de dominio (un precio no negativo, un estado dentro de un conjunto).
class CreateOrders < ActiveRecord::Migration[7.1]
  def change
    create_table :orders do |t|
      t.references :customer, null: false, foreign_key: true
      t.string  :status, null: false, default: "pending"
      t.integer :total_cents, null: false
      t.timestamps
    end

    add_index :orders, [:customer_id, :created_at]

    # Invariantes impuestas por la BD, no por Rails
    add_check_constraint :orders, "total_cents >= 0", name: "total_non_negative"
    add_check_constraint :orders,
      "status IN ('pending','paid','shipped','cancelled')",
      name: "status_valid"
  end
end

¿Por qué tanto rigor si Rails ya valida? Porque —y este es el punto que más cuesta aceptar— las validaciones de Rails no garantizan la integridad.

Las validaciones no dan integridad (pero son geniales para la UX)

Copeland es contundente aquí, y tiene toda la razón. Una validación de Active Record se puede saltar de mil maneras:

  • update_column y update_all no disparan validaciones.
  • Una condición de carrera entre dos requests puede colar dos registros que individualmente parecían únicos.
  • Otra aplicación, un script de mantenimiento o una consola pueden escribir directamente en la base de datos.
  • Un save(validate: false) explícito.

Si la única defensa es una validación en Ruby, tarde o temprano tendrás datos corruptos. La integridad de verdad solo la garantiza la base de datos. Por eso Copeland propone un patrón de doble cinturón: la constraint en la base de datos para que la corrupción sea imposible, y la validación en Rails para que el usuario reciba un mensaje amable.

class Order < ApplicationRecord
  belongs_to :customer
  # Validación: para la experiencia de usuario (mensajes bonitos)
  validates :status, inclusion: { in: %w[pending paid shipped cancelled] }
  validates :total_cents, numericality: { greater_than_or_equal_to: 0 }
end

La validación existe para que el formulario muestre "el total no puede ser negativo" en vez de un error 500. La constraint existe para que, pase lo que pase y escriba quien escriba, ese dato nunca llegue a la tabla. Las dos cosas, no una.

Migraciones correctas y testeables

De aquí sale que las migraciones no son solo "crear tablas": son donde codificas la corrección del sistema. Una migración sostenible incluye sus constraints, sus índices y es reversible. Y —detalle que casi nadie hace— Copeland recomienda escribir tests para las constraints de base de datos, para verificar que de verdad protegen lo que crees:

test "no se puede guardar un pedido con total negativo" do
  assert_raises(ActiveRecord::StatementInvalid) do
    Order.connection.execute(
      "INSERT INTO orders (customer_id, status, total_cents, created_at, updated_at)
       VALUES (1, 'pending', -100, now(), now())"
    )
  end
end

Saltándote Rails a propósito (SQL crudo) compruebas que la base de datos rechaza el dato por sí misma. Si el test pasa, tu integridad no depende de que nadie use mal Active Record.

Callbacks: úsalos lo mínimo

El capítulo de modelos vuelve sobre los callbacks (before_save, after_create...) con una advertencia que conecta con la tercera entrega. Los callbacks esconden comportamiento: un after_create que manda un email convierte un simple Order.create en algo con efectos secundarios invisibles, que se disparan también en los tests, en los seeds y en la consola, casi siempre cuando no los quieres.

La regla de Copeland: callbacks lo mínimo imprescindible, y reservados para cosas estrictamente ligadas a la persistencia (normalizar un campo antes de guardar). Los side effects de negocio —emails, notificaciones, integraciones— van al seam, disparados explícitamente desde el servicio, donde se ven y se controlan.

Los scopes a menudo son lógica de negocio

Otro punto fino: un scope como scope :active, -> { where(status: "active") } parece inocente, pero "qué cuenta como activo" suele ser una regla de negocio disfrazada de consulta. Cuando un scope encierra criterios de dominio que cambian, Copeland sugiere que esa decisión pertenece al seam, no escondida en el modelo. Los scopes triviales de acceso a datos están bien; los que codifican política de negocio, sospechosos.

Estrategia de testing de modelos

Sobre cómo testear modelos, el consejo es proporcional: no testees que Rails funciona (no escribas un test para comprobar que validates :presence valida presencia, eso lo testea Rails). Testea tu lógica: tus constraints de base de datos, tus validaciones con reglas propias, tus métodos de consulta no triviales. El resto es ruido que infla la suite sin protegerte de nada.

Mi versión

Este capítulo es el que más fácil me resulta defender, quizá porque vengo de bases de datos. He visto demasiados esquemas Rails sin una sola foreign key "porque Rails ya gestiona las asociaciones". Y luego, sorpresa: registros huérfanos, datos imposibles, una columna status con quince valores distintos que nadie esperaba porque en algún momento un script escribió directamente. La base de datos lleva décadas sabiendo proteger datos; renunciar a eso por comodidad es de las decisiones más caras a largo plazo.

El patrón de doble cinturón —constraint + validación— es ya automático en mi forma de trabajar: la constraint para que sea imposible, la validación para que sea amable. Y lo de testear las constraints con SQL crudo me ha pillado más de un esquema donde creía tener una restricción que en realidad nunca se aplicó.

Donde más cruzada personal tengo es con los callbacks. Un after_save que manda un email es una bomba de relojería: te explota el día que escribes un seed o un test que crea mil registros y manda mil emails. Side effects al seam, siempre explícitos. El modelo guarda y lee; nada más.

Lo que viene

Con los modelos en su sitio y los datos protegidos por la base de datos, ya tenemos cimientos sólidos de verdad. En el próximo post abordamos algo que recorre todo el libro pero que Copeland trata con especial cuidado: el testing. Veremos el valor y el coste de los tests, cuándo usar rack_test y cuándo un navegador real, cómo combatir los tests frágiles con data-testid, y un ejemplo end-to-end completo que une todo lo que hemos visto hasta ahora.