← Todos los posts

Cómo estructuro una app Rails en 2026

TL;DR

  • La lógica de negocio no va en el modelo — el modelo es persistencia y relaciones, nada más
  • Los concerns son para comportamiento compartido, no para ocultar complejidad dentro del modelo
  • Service objects para flujos complejos, módulos para operaciones sin estado, modelos puros para queries y asociaciones
  • Rails 8 con Solid Queue + Solid Cache + Kamal cambia el stack por defecto — menos decisiones de infraestructura, más tiempo para el producto
  • La arquitectura correcta es la más simple que puedes defender en un code review — no la más sofisticada que puedas imaginar

De dónde vengo

Llevo más de diez años escribiendo Rails. He visto apps de todos los tamaños: desde startups con un modelo User y un modelo Post, hasta monolitos con 200 modelos y 50K líneas de código. He trabajado con DDD estricto, con “pure Rails way”, con hexagonal architecture que nadie en el equipo entendía excepto el que la propuso, y con todo lo que hay en el medio.

Esta no es la arquitectura definitiva. Es la que uso hoy, en 2026, para proyectos que tienen que crecer de forma manejable sin un equipo de arquitectos. Si trabajas en un equipo de 50 devs con requerimientos de compliance, esto probablemente no es suficiente para ti. Para todos los demás: sigue leyendo.

El modelo: persistencia y relaciones, nada más

El error más común que veo en código Rails: meter lógica de negocio en el modelo porque “es donde vive el dato”.

# Lo que no quiero en el modelo
class User < ApplicationRecord
  def send_welcome_sequence
    WelcomeMailer.day_1(self).deliver_later
    WelcomeMailer.day_3(self).deliver_later(wait: 3.days)
    SlackNotifier.new_user(self)
    HubspotClient.create_contact(self)
  end

  def calculate_ltv
    orders.completed.sum(:total) * renewal_probability
  end

  def upgrade_to_pro!(payment_token)
    # lógica de Stripe, actualización de plan, emails, jobs...
  end
end

# Lo que sí quiero en el modelo
class User < ApplicationRecord
  belongs_to :company
  has_many :orders
  has_many :subscriptions

  scope :active, -> { where(deactivated_at: nil) }
  scope :pro, -> { where(plan: 'pro') }
  scope :trial_expiring, -> { where(trial_ends_at: 1.week.from_now..) }

  def pro?
    plan == 'pro'
  end

  def trial_expired?
    trial_ends_at&.past?
  end

  def display_name
    full_name.presence || email.split('@').first
  end
end

El modelo guarda datos, define relaciones y scopes, y tiene predicados simples (pro?, trial_expired?). Todo lo que implique hablar con servicios externos, orquestar múltiples modelos, o tener efectos secundarios, sale del modelo.

Dónde va la lógica de negocio

app/
  models/          ← persistencia, relaciones, scopes, predicados simples
  controllers/     ← HTTP: parsea input, llama services, renderiza respuesta
  services/        ← flujos de negocio complejos con efectos secundarios
  queries/         ← queries AR complejas que no pertenecen a un solo modelo
  lib/             ← código que no depende de Rails (parsers, clients, utils)
  jobs/            ← background work
  mailers/         ← emails
  policies/        ← autorización (Pundit)

Services para flujos complejos

# app/services/user_upgrade_service.rb
class UserUpgradeService
  def initialize(user, payment_params)
    @user = user
    @payment_params = payment_params
  end

  def call
    charge = process_payment
    activate_pro_features
    notify_user
    track_conversion
    charge
  end

  private

  def process_payment
    Stripe::Subscription.create(
      customer: @user.stripe_customer_id,
      items: [{ price: ENV['STRIPE_PRO_PRICE_ID'] }],
      payment_behavior: 'default_incomplete',
    )
  rescue Stripe::CardError => e
    raise PaymentError, e.message
  end

  def activate_pro_features
    @user.update!(plan: 'pro', upgraded_at: Time.current)
    @user.company.update!(plan: 'pro') if @user.company.on_team_plan?
  end

  def notify_user
    UserMailer.upgrade_confirmation(@user).deliver_later
  end

  def track_conversion
    Analytics.track('user_upgraded', user_id: @user.id, plan: 'pro')
  end
end

Queries para consultas complejas

# app/queries/churned_users_query.rb
class ChurnedUsersQuery
  def initialize(relation = User.all)
    @relation = relation
  end

  def call(period: 30.days)
    @relation
      .where(plan: 'pro')
      .where('last_active_at < ?', period.ago)
      .where(churned_at: nil)
      .left_joins(:subscriptions)
      .where(subscriptions: { status: 'canceled' })
      .select('users.*, subscriptions.canceled_at')
  end
end

# Uso
ChurnedUsersQuery.new.call(period: 14.days)
ChurnedUsersQuery.new(company.users).call  # scoped a una company

Las queries complejas que involucran múltiples modelos o condiciones complicadas no pertenecen a ningún modelo en particular. La clase Query las contiene y las hace testeables en aislamiento.

Concerns: solo para comportamiento compartido real

Los concerns tienen mala reputación porque se usan para ocultar fat models en lugar de resolverlos. El uso correcto:

# BIEN — comportamiento real compartido entre modelos
module Publishable
  extend ActiveSupport::Concern

  included do
    scope :published, -> { where.not(published_at: nil) }
    scope :draft, -> { where(published_at: nil) }
  end

  def published?
    published_at.present?
  end

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

  def unpublish!
    update!(published_at: nil)
  end
end

class Post < ApplicationRecord
  include Publishable
end

class Page < ApplicationRecord
  include Publishable
end

# MAL — concern como escondite de complejidad
module UserBillingConcern
  # 200 líneas de lógica de Stripe que "viven" en el modelo User
  # porque alguien no quería tener un modelo "gordo"
  # → el modelo sigue siendo gordo, solo está fragmentado
end

La pregunta para validar un concern: ¿este comportamiento existe en más de un modelo y tiene sentido como unidad cohesiva? Si la respuesta es “no, solo es para dividir el modelo User en partes más pequeñas”, es el uso incorrecto.

El stack de Rails 8 en 2026

Rails 8 llegó con defaults que eliminan decisiones de infraestructura que antes eran obligatorias:

Solid Queue (ya lo cubrimos): jobs sin Redis. Para la mayoría de las apps, es suficiente. Solo llegas a Sidekiq si tienes volume real.

Solid Cache: caché sin Redis. Mismo argumento — si ya tienes Postgres, tienes caché.

Kamal: deploy sin Kubernetes. Un VPS con Docker, zero-downtime deploys, sin la complejidad de k8s.

Propshaft: asset pipeline simplificado. Sin Webpack, sin complicaciones.

El resultado en práctica: una app Rails 8 nueva puede desplegarse en un VPS de $20/mes con Kamal, sin Redis, sin Kubernetes, con Postgres manejando tanto la DB como los jobs y el caché. Para una startup en sus primeras etapas, eso es mucho menos overhead operacional.

# config/deploy.yml — Kamal en 2026
service: myapp
image: ghcr.io/myuser/myapp

servers:
  web:
    hosts:
      - 123.45.67.89
    options:
      network: myapp

  workers:
    hosts:
      - 123.45.67.89
    cmd: bundle exec rake solid_queue:start

proxy:
  ssl: true
  host: myapp.com

env:
  clear:
    RAILS_ENV: production
    SOLID_QUEUE_IN_PUMA: false
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL

La decisión más importante: cuándo NO agregar capas

La tentación de over-engineerear es real. Después de suficiente tiempo leyendo sobre DDD, hexagonal architecture, y CQRS, es fácil querer aplicar todo a una app que tiene 50 users. He hecho esto. Le he explicado a un cliente por qué su CRUD de tareas “necesitaba” un domain layer. No fue mi momento más honesto.

Mi regla práctica: no agregues una capa de arquitectura hasta que el dolor de no tenerla sea concreto y frecuente.

  • Si el controller tiene 10 líneas y hace una cosa, no necesita un service object
  • Si el modelo tiene 5 métodos simples, no necesita concerns
  • Si tienes una query que se usa en un solo lugar, ponla ahí directamente

La deuda técnica real no es código simple — es código innecesariamente complejo que alguien tiene que navegar el día que hay un bug en producción.

# Esto está perfectamente bien para un CRUD simple
class PostsController < ApplicationController
  def create
    @post = current_user.posts.build(post_params)

    if @post.save
      redirect_to @post, notice: 'Post creado'
    else
      render :new, status: :unprocessable_entity
    end
  end
end

No necesita un CreatePostService. No hasta que el proceso de crear un post tenga efectos secundarios, lógica condicional compleja, o necesite ser reutilizado en otro contexto.


La arquitectura que funciona es la que puedes explicarle a un developer nuevo en 20 minutos y que puedan empezar a contribuir ese mismo día. Rails ya tiene buenos defaults — el trabajo es no alejarse demasiado de ellos sin una razón concreta.

Y en 2026, con Rails 8, esos defaults son mejores que nunca. No los desperdicies tratando de convertir Rails en algo que no es.