Arquitectura Rails para proyectos de un solo dev: Cómo no quemarse

26 años desarrollando solo. Aquí está la arquitectura que realmente funciona sin convertirse en deuda técnica.

Introducción: El Dilema del Dev solitario

Hace 22 años me tocó elegir entre dos caminos.

Opción A: Arquitectura “perfecta” — patrones SOLID, tests exhaustivos, separación de concerns quirúrgica, capa de servicios, capa de presentación, inyección de dependencias. Hermoso. Mantenible. Paralizante para uno solo.

Opción B: “Write a feature, ship it, learn from users” — Un caos controlado donde puedo iterar a velocidad de startup, pero que no explote en mis manos en el mes 6.

Elegí opción B con reglas. Y después de construir desde sistemas de cotización complejos (Alugandia) hasta plataformas fitness (ChuletonApp), he encontrado la fórmula que funciona. No es perfecta. Es pragmática.

Este post es lo que aprenderé haciendo esto 3 veces más.


El Problema Real: No es el código, es tu tiempo

Cuando eres solo tú, la ecuación es diferente que en un equipo:

MétricaEquipo de 5 devsDev solitario
Tiempo leyendo código60%5%
Tiempo escribiendo código40%65%
Costo de refactor tontoBajo (otro lo ve)MUY ALTO (estás solo)
ROI de test unitario al day 1MedioNegativo
ROI de documentaciónAltoNegativo (cambias todo)

El resultado: Si optimizas para “equipo perfecto”, mueres de hambre. Si optimizas para “ir rápido sin cuidado”, heredas deuda que te frena en mes 4.

La respuesta: Optimizar para velocidad inicial + decisiones inteligentes sobre qué mantener.


Los 4 Pilares de una Arquitectura Rails Solo-Dev

He condensado 22 años de errores en 4 pilares. No son revolucionarios. Pero funcionan.

1️⃣ Modelos Obesos, Controllers Esqueléticos

Rails promete MVC. Lamento decirte que MVC es mentira para código único.

Lo que veo en código “limpio”:

# app/controllers/quotes_controller.rb - "Limpio"
class QuotesController < ApplicationController
  def create
    quote = Quote.new(quote_params)
    quote.calculate_total
    quote.apply_discounts
    quote.validate_materials
    if quote.save
      QuoteMailer.send_confirmation(quote).deliver_later
      redirect_to quote
    end
  end
end

Lo que funciona en realidad (ChuletonApp + Alugandia):

# app/models/quote.rb - "Obeso pero legible"
class Quote < ApplicationRecord
  belongs_to :user
  has_many :line_items
  
  validates :user, presence: true
  
  enum status: { draft: 0, sent: 1, accepted: 2, rejected: 3 }
  
  # TODO: Métodos que viven en Quote
  def calculate_total
    line_items.sum(&:subtotal)
  end
  
  def apply_discounts
    return unless customer_discount
    self.total = total * (1 - customer_discount / 100.0)
  end
  
  def confirm_and_send
    self.status = :sent
    save!
    QuoteMailer.send_confirmation(self).deliver_later
    ActivityLog.record(user, "quote_sent", id)
  end
  
  private
  
  def customer_discount
    case user.tier
    when :gold then 15
    when :silver then 10
    else 0
    end
  end
end

# app/controllers/quotes_controller.rb - Esquelético
class QuotesController < ApplicationController
  def create
    quote = Quote.new(quote_params)
    quote.confirm_and_send ? (redirect_to quote) : (render :new)
  end
end

¿Por qué funciona?

  • Controllers: 3-5 líneas. Si entiendes el modelo, entiendes el flujo.
  • Cambios futuros: Edito un modelo, no 3 clases diferentes.
  • Testing: Test el modelo una vez, controllers casi no fallan.
  • Mantenimiento solo: Todo en un lugar que conoces.

⚠️ Límite de tolerancia: Cuando el modelo toca >500 líneas → extraer a app/services/ (pero esto lleva 6+ meses en solo-dev).


2️⃣ Servicios Simples (No inyección de dependencias)

Una verdad incómoda: Dependency Injection es una solución a un problema de equipo.

Cuando trabajas solo, tu “inyector de dependencias” eres tú. Tu cerebro.

Lo que NO hagas:

# DON'T: Sobrecomplejidad prematura
class StravaApiService
  def initialize(http_client = StravaHttpClient.new, logger = Logger.new)
    @http_client = http_client
    @logger = logger
  end
  
  def fetch_activities(user)
    @logger.info "Fetching activities for #{user.id}"
    @http_client.get("/api/activities", token: user.strava_token)
  end
end

Lo que SÍ hagas:

# DO: Servicios aburridos que funcionan
class StravaSync
  def self.sync_user(user)
    new(user).sync
  end
  
  def initialize(user)
    @user = user
  end
  
  def sync
    activities = fetch_from_strava
    store_locally(activities)
    update_leagues(@user, activities)
  end
  
  private
  
  def fetch_from_strava
    # llamada directa a API
    conn = Faraday.new(url: 'https://www.strava.com/api/v3')
    conn.authorization :Bearer, @user.strava_token
    conn.get('/athlete/activities').body
  end
end

# En un job:
class SyncStravaJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)
    StravaSync.sync_user(user)
  rescue => e
    ErrorTracker.notify(e, user: user_id)
  end
end

¿Por qué?

  • 1-2 métodos públicos claros
  • Testing: mock lo mínimo (Strava API)
  • Debugging: stack trace te muestra exactamente dónde falló
  • Reutilización: un job lo llama, un admin rake lo llama, easy

Cuándo extraer a servicio: Cuando la misma lógica aparece en 2+ controllers.


3️⃣ Testing Pragmático (No purista)

El mito: “100% coverage”. La realidad: pierdes 4 semanas escribiendo tests para edge cases que nunca pasan.

Mi regla del 80/20 para solo-dev:

# Sí testea ESTO (lógica de negocio):
RSpec.describe Quote, type: :model do
  describe '#calculate_total_with_discounts' do
    it 'applies customer tier discount correctly' do
      user = create(:user, tier: :gold)
      quote = create(:quote, user: user, subtotal: 100)
      expect(quote.total_after_discount).to eq(85) # 15% gold discount
    end
  end
end

# No testees ESTO (Rails magic):
RSpec.describe User, type: :model do
  it 'validates presence of email'  # Rails lo valida
  it 'creates a timestamp'          # Rails lo hace
  it 'encrypts password'            # Devise ya lo testea
end

# Sistema de tests real en ChuletonApp:
# ✅ Punto scoring: probado 100% (es el core)
# ✅ Validación Garmin OAuth: probado (falla costoso)
# ✅ Cálculo ranking: probado 100%
# ❌ Vistas: probado con screenshot solo
# ❌ Emails: probado manual (son templates)
# ❌ Job scheduling: probado manual (es n8n, no código)

Cobertura real ChuletonApp: 45% por herramienta, pero 100% de rutas críticas.

Ahorro de tiempo: 20 horas de tests que no escribo = 5 features más.


4️⃣ Estructura de Carpetas: Simple y Predecible

Evita la “carpeta de servicios del caos” donde viven 47 archivos sin relación.

Mi estructura (después de 22 años):

app/
├── models/           # Lógica de negocio + validaciones
│   ├── user.rb
│   ├── quote.rb
│   └── line_item.rb
├── controllers/      # Routing → modelo → render
│   └── quotes_controller.rb
├── jobs/             # Background tasks
│   ├── sync_strava_job.rb
│   └── send_digest_email_job.rb
├── services/         # Solo si aparecen 2+ veces
│   └── strava_sync.rb
├── mailers/          # Emails
├── views/
├── lib/              # Utilidades que no son Rails
│   └── point_calculator.rb
└── concerns/         # Mixins reutilizables (raro)

config/
├── routes.rb
└── initializers/
    └── sidekiq.rb

Regla de oro: Si no sabes dónde poner algo, va en models/ primero. Cuando crezca (6+ meses), la mueves.

Qué NO hago:

  • app/presenters/ (overkill para HTML)
  • app/decorators/ (lógica de vista = en modelo)
  • app/repositories/ (ActiveRecord YA es repository)
  • app/serializers/ (jBuilder o JSON:API lo hace todo)

Patrones Concretos que Funcionan

Patrón: Polymorphic Jobs para múltiples contextos

En Alugandia, necesitaba mandar notificaciones por WhatsApp, Email, y Slack.

# app/models/notification.rb
class Notification < ApplicationRecord
  belongs_to :notifiable, polymorphic: true
  
  enum channel: { email: 0, whatsapp: 1, slack: 2 }
  
  after_create :queue_delivery
  
  private
  
  def queue_delivery
    case channel
    when 'email'
      NotificationMailer.send_notification(self).deliver_later
    when 'whatsapp'
      SendWhatsappJob.perform_later(id)
    when 'slack'
      SendSlackJob.perform_later(id)
    end
  end
end

# Usage:
quote = Quote.last
Notification.create!(
  notifiable: quote,
  channel: :whatsapp,
  recipient_phone: user.phone
)

Ventaja: Un modelo, múltiples comportamientos. Un cambio = en un lugar.


Patrón: Concern para lógica reutilizada (raro, pero efectivo)

En ChuletonApp y Alugandia, ambos necesitan “registrar cambios”.

# app/models/concerns/auditable.rb
module Auditable
  extend ActiveSupport::Concern
  
  included do
    has_many :audit_logs, as: :auditable
    
    after_update :log_changes
  end
  
  private
  
  def log_changes
    changes.each do |field, (old_val, new_val)|
      AuditLog.create!(
        auditable: self,
        field: field,
        old_value: old_val,
        new_value: new_val,
        changed_by: Current.user
      )
    end
  end
end

# app/models/quote.rb
class Quote < ApplicationRecord
  include Auditable
end

# app/models/user.rb
class User < ApplicationRecord
  include Auditable
end

Cuándo usar: Cuando la lógica aparece en 2+ modelos Y es compleja. Si es simple, no.


Métricas de Verdad: Cómo midió esto

Llevo datos desde 2006. Aquí está la realidad:

ProyectoAños activoTiempo dev/semanaLíneas modeloTests %Deuda técnica
Alugandia CRM3 años5h~2,50025%Media
ChuletonApp4 meses10h~1,20045%Baja
erisso WP themes8 años2h~80015%Alta

Hallazgo clave: Deuda técnica correlaciona con tests, NO con líneas de código.

Hallazgo 2: Los proyectos con >1,500 líneas en un modelo → empecé a extraer servicios, la velocidad mejoró.

Hallazgo 3: Refactorizar “código limpio” es como reescribir un libro. Refactorizar “código pragmático” es editar párrafos.


❌ Errores que cometí (y aprendí)

Error 1: “Voy a usar Sidekiq para todo”

Creé 47 jobs. Algunos disparados cada 5 minutos.

El problema: Debugging distribuido = infierno solitario. Un job falla silenciosamente, un cliente espera 2 horas.

La solución: Ahora:

  • ✅ Jobs para lo que tarda >5s (sync API, reportes)
  • ❌ Jobs para lo que tarda <1s (notificaciones triviales → inline)
  • 📊 Monitoreo básico (alertas si job falla)

Código arreglado:

# app/models/quote.rb
def confirm_and_send
  self.status = :sent
  save!
  
  # Esto es fast, hazlo síncrono
  QuoteMailer.send_confirmation(self).deliver_later
  
  # Esto es lento, hazlo async
  NotifySlackJob.perform_later(id)
  
  # Esto es crítico, hazlo ahora o falla visiblemente
  update_league_rankings!
end

Tiempo ahorrado: 40 horas de debugging de jobs fantasma.


Error 2: “Voy a seguir TDD al pie de la letra”

Escribí tests ANTES de código. Perfecto. Ingeniero.

El problema: Refactoricé mi código 5 veces hasta que “se sintiera bien”. Los tests se rompieron 5 veces. Pasé más tiempo haciendo pasar tests que construyendo features.

La solución: Ahora:

  • ✅ TDD para lógica crítica (cálculos, scoring)
  • ❌ TDD para exploración temprana (“¿cómo se vería esta feature?”)
  • 🔄 Red → Green → Refactor, pero no obsesionarse con coverage

Tiempo ahorrado: 15 horas/mes de frustración con tests.


Error 3: “Voy a documentar todo impecablemente”

Escribí README hermosos, comentarios quirúrgicos, docstrings.

El problema: 3 meses después el código cambió. La documentación se quedó atrás. Ahora sirve de engaño.

La solución: Ahora:

  • ❌ README extenso (nadie lo lee)
  • ✅ README con: instrucciones setup (copy-paste), 1 diagrama del flujo (5 min)
  • ✅ Comentarios SOLO en lógica WTF (por qué, no qué)
  • ✅ Código lo suficientemente claro para no necesitar comentarios

Tiempo ahorrado: 10 horas/mes de documentación muerta.


🎯 Checklist: ¿Tu arquitectura Rails está lista para solo-dev?

MODELOS & LÓGICA
☐ Lógica de negocio = en modelos
☐ Validaciones = en modelos
☐ Controllers < 50 líneas
☐ Métodos en modelo < 30 líneas (indicio refactor)

SERVICIOS & JOBS
☐ Servicios solo si lógica aparece 2+ veces
☐ Jobs solo para tareas > 5 segundos
☐ Cada job tiene logging básico
☐ Jobs tienen reintentos automáticos

TESTING
☐ Lógica crítica = testada (80%+)
☐ Validaciones = testeadas
☐ Vistas = screenshot manuales OK
☐ No test obsession: 40-50% coverage está bien

ESTRUCTURA
☐ app/ folder sigue patrón consistente
☐ No hay carpetas "misc" o "utils"
☐ lib/ tiene código NO-Rails
☐ config/initializers tiene 1-2 archivos máximo

DEPLOYMENT & MONITORING
☐ Error tracking (Sentry/Rollbar/similar)
☐ Basic health checks (monitoring)
☐ Logs centralizados (al menos locales)
☐ Database backups automáticos

🚀 Conclusión: La arquitectura real

Después de 22 años, esto es lo que aprendí:

1. Perfecta es enemiga de Shipped

Un modelo “obeso” pero que funciona hoy es mejor que una arquitectura hermosa que será refactorizada mañana.

2. Tu tiempo es la unidad de medida

No líneas de código. No test coverage. Tu tiempo.

Si una práctica te ahorra 5 horas/semana: hazla. Si te ahorra 30 minutos pero toma 2 horas mantenerla: no la hagas.

3. La arquitectura es una decisión temporal

Hoy (mes 1-6) optimizo para velocidad. Mañana (mes 6-12) optimizo para mantenibilidad. Después (año 2+) puedo permitirme refactores.

4. Tú eres el único code reviewer

Las prácticas que importan son las que vas a recordar y mantener. No las que dice Twitter que son “best practices”.


📚 Para profundizar

  • Growing Rails Applications (graceful degradation)
  • POODR de Sandi Metz (diseño pragmático)
  • The Rails Way de Obie Fernandez (si quieres ortodoxia)

Pero honestamente: lee tu propio código de hace 6 meses. Eso te enseña más.


¿Tienes una arquitectura diferente que te funciona?

Este post es lo que a mí me funciona con 10-20h/semana. Probablemente tú tienes otro sistema.


Tech Stack: Rails 7+, PostgreSQL, Sidekiq, Docker
Tiempo de lectura: 12 minutos
Aplicable a: Solopreneurs, pequeños equipos (2-3 devs)
Próximo post: “Testing Rails en 30 minutos: Lo que necesitas”