ConTodo
Ruby on Rails Principal Engineer

ConTodo ERP — Arquitectura Backend Ruby on Rails: Modular Monolith, DDD, Event-Driven y Multi-Tenant

Arquitectura Backend de ConTodo ERP (Ruby on Rails)

Alcance. Este documento define la arquitectura del backend de ConTodo: un modular monolith sobre Ruby on Rails 7.2 (Ruby 3.3), organizado por bounded contexts (Domain-Driven Design), con comunicación interna mediante eventos de dominio y outbox pattern, exposición de APIs REST versionadas y webhooks, y una estrategia multi-tenant robusta a nivel de Rails y PostgreSQL. Se priorizan integridad transaccional contable, aislamiento entre empresas, evolución hacia microservicios solo donde el negocio lo justifique, y operabilidad en AWS ECS. Se incluyen supuestos explícitos, riesgos, alternativas evaluadas y diagramas mermaid.


1. Principios de arquitectura y supuestos

#Decisión / SupuestoJustificaciónAlternativa descartada
A1Modular Monolith con packwerk (packs por bounded context), no microservicios desde el día 1.Un ERP tiene transacciones cross-módulo (venta → kardex → contabilidad → tesorería) que exigen consistencia ACID. Microservicios introducen sagas distribuidas y latencia que matan time-to-market de una PYME-targeted SaaS.Microservicios prematuros: complejidad operativa 5x sin demanda de escala que la justifique.
A2Rails 7.2 + Ruby 3.3 (YJIT activo), PostgreSQL 16, Redis 7, Sidekiq 7.YJIT da ~15-25% throughput extra sin cambios de código. PG16 mejora paralelismo y MERGE. Stack maduro, contratable en Perú/LATAM.Hanami/dry-rb: ecosistema menor, curva de equipo más alta.
A3Multi-tenancy híbrida: discriminador por columna company_id + Row Level Security (RLS) de PostgreSQL como red de seguridad de último nivel.RLS protege aunque un dev olvide un where. Una sola base de datos simplifica reportes consolidados y migraciones vs. schema-por-tenant.Schema-por-tenant (Apartment): migraciones O(n) tenants, joins consolidados imposibles. DB-por-tenant: costo RDS inviable para miles de PYMEs.
A4Eventos de dominio + Outbox pattern transaccional. El evento se persiste en la misma transacción que el cambio de estado; un relay Sidekiq lo publica después.Garantiza at-least-once sin dual-write inconsistente (DB commit + broker). Crítico para "venta confirmada ⇒ asiento contable".Publicar directo a Redis/SNS dentro del request: si el broker cae tras el commit, se pierde el evento.
A5CQRS ligero: comandos (escritura) vía service objects / interactors; lecturas pesadas (BI, reportes) vía vistas materializadas y read replicas.Reportes financieros y kardex valorizado no deben competir por locks con la operación transaccional.CQRS+Event Sourcing completo: sobre-ingeniería para v1; se reserva para módulos de auditoría.
A6API REST versionada (/api/v1) con JSON:API-like, paginación cursor + offset, idempotency keys en POST. GraphQL pospuesto.REST es trivial de cachear (CloudFront/WAF) y de integrar con SUNAT/bancos. Idempotency evita asientos duplicados ante reintentos.GraphQL como entrada única: N+1 y autorización por campo complican el control fiscal.
A7Autorización con Pundit + scopes tenant-aware; autenticación con devise + JWT (access/refresh) y rolify para RBAC multi-rol/multi-sucursal.Pundit policies son testeables unitariamente; el RBAC del ERP exige permisos por módulo × sucursal × acción.CanCanCan: ability central monolítica difícil de auditar en Big Four review.

2. Mapa de bounded contexts (DDD)

Cada módulo del producto se modela como un bounded context con su propio lenguaje ubicuo, agregados y eventos. La comunicación entre contexts es asíncrona por eventos salvo invariantes que exigen transacción única (marcadas como call síncrono in-process).

Reglas de dependencia (enforced por packwerk):

  • SharedKernel no depende de nadie; todos pueden depender de él.
  • Operaciones y Finanzas nunca se referencian por clase directamente; solo por eventos o por published APIs explícitas (Sales::PublicApi).
  • IAM/ORG son dependencias permitidas de forma síncrona (el tenant y el usuario están en todo request).

3. Estructura de carpetas (packs)

contodo/
├── app/                          # Capa Rails "delgada" (controllers, jobs base)
│   ├── controllers/api/v1/       # Solo orquestación HTTP → comandos de packs
│   ├── channels/                 # ActionCable (notificaciones realtime)
│   └── jobs/                     # ApplicationJob, OutboxRelayJob
├── packs/                        # Bounded contexts (packwerk)
│   ├── shared_kernel/
│   │   ├── app/
│   │   │   ├── models/value_objects/    # Money, ExchangeRate, Period
│   │   │   ├── events/                  # DomainEvent (base), EventBus
│   │   │   └── tenancy/                 # Current, TenantResolver, RLS setter
│   │   └── package.yml
│   ├── identity_access/
│   │   ├── app/
│   │   │   ├── models/                  # User, Role, Permission, Membership
│   │   │   ├── policies/                # Pundit policies base tenant-aware
│   │   │   ├── services/                # AuthenticateUser, IssueTokens
│   │   │   └── public/                  # IdentityAccess::PublicApi
│   │   ├── spec/
│   │   └── package.yml
│   ├── organization/             # Company, Branch, Warehouse, Settings
│   ├── inventory/
│   │   ├── app/
│   │   │   ├── models/                  # Product, StockItem, KardexEntry, Lot
│   │   │   ├── aggregates/              # KardexLedger (valorización PEPS/Prom)
│   │   │   ├── commands/                # ReceiveStock, IssueStock, AdjustStock
│   │   │   ├── events/                  # StockValued, StockReserved
│   │   │   ├── subscribers/             # OnGoodsReceived, OnSaleConfirmed
│   │   │   └── public/                  # Inventory::PublicApi
│   │   └── package.yml
│   ├── purchasing/   ├── sales/   ├── manufacturing/   ├── logistics/   ├── imports/
│   ├── accounting/
│   │   ├── app/
│   │   │   ├── models/                  # Account(PCGE), JournalEntry, Line
│   │   │   ├── commands/                # PostJournalEntry, ReverseEntry
│   │   │   ├── policies/                # PostingRule (mapeo evento→asiento)
│   │   │   └── subscribers/             # OnSaleConfirmed → genera asiento
│   │   └── package.yml
│   ├── treasury/   ├── financial_statements/
│   ├── human_resources/   ├── payroll/   ├── crm/
│   ├── business_intelligence/   └── ai/
├── config/
│   ├── initializers/packwerk.rb
│   ├── initializers/sidekiq.rb
│   └── routes.rb                 # mounts namespaced API engines
├── db/
│   ├── migrate/
│   └── views/                    # SQL de vistas materializadas (kardex, EEFF)
└── lib/
    └── tasks/                    # outbox:relay, tenancy:backfill_rls

4. Estrategia multi-tenant a nivel Rails

4.1 Resolución de tenant y Current

Cada request resuelve el tenant desde el JWT (claim company_id) o subdominio (acme.contodo.pe). Se almacena en un objeto Current (ActiveSupport::CurrentAttributes) y se propaga a PostgreSQL vía variable de sesión para activar RLS.

# packs/shared_kernel/app/tenancy/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :company_id, :user, :branch_id, :request_id
end

# packs/shared_kernel/app/tenancy/tenant_setter.rb
module Tenancy
  class TenantSetter
    def self.with(company_id)
      ActiveRecord::Base.connection.execute(
        ActiveRecord::Base.sanitize_sql(["SET LOCAL app.current_company_id = ?", company_id])
      )
      yield
    end
  end
end
-- Migración: política RLS por tabla tenantizada
ALTER TABLE journal_entries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON journal_entries
  USING (company_id = current_setting('app.current_company_id')::bigint);

4.2 Default scope automático

Un concern Tenantable añade el company_id por defecto en escritura y un scope implícito, pero RLS sigue siendo la autoridad final (defensa en profundidad). Los jobs de Sidekiq serializan company_id en sus args y re-aplican TenantSetter.with antes de ejecutar — un job sin tenant explícito falla rápido (fail-closed).

CapaMecanismo de aislamientoFalla segura
HTTPMiddleware resuelve tenant; rechaza request sin company_id.401/403
ORMTenantable default scope + asignación automática.Excepción si falta Current.company_id
DB (RLS)Política USING company_id = current_setting(...).0 filas devueltas (no fuga)
BackgroundTenantSetter.with(args[:company_id]) envuelve cada job.Job aborta sin tenant

5. Eventos de dominio y Outbox pattern

5.1 Flujo transaccional

5.2 Tabla outbox y contrato de evento

CREATE TABLE outbox_events (
  id           BIGSERIAL PRIMARY KEY,
  company_id   BIGINT NOT NULL,
  event_id     UUID NOT NULL UNIQUE,        -- idempotency key
  name         TEXT NOT NULL,               -- 'sales.sale_confirmed'
  schema_ver   INT  NOT NULL DEFAULT 1,
  aggregate    TEXT NOT NULL,               -- 'Sale#1234'
  payload      JSONB NOT NULL,
  occurred_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished ON outbox_events (id) WHERE published_at IS NULL;
AtributoGarantía
EntregaAt-least-once → consumidores idempotentes por event_id.
OrdenPor agregado (no global); el relay procesa FIFO por id con SKIP LOCKED.
Versionadoschema_ver permite evolución de payload sin romper consumidores antiguos.
Reprocesooutbox:replay re-publica rango por fecha para reconstruir read models de BI.

6. Diseño de APIs REST

6.1 Convenciones

AspectoDecisión
VersionadoURL path /api/v1. Deprecación con header Sunset y 6 meses de solape.
PaginaciónCursor (?cursor=...&limit=50) para listados grandes (kardex, asientos); offset para catálogos pequeños. Metadatos en meta.
IdempotenciaHeader Idempotency-Key obligatorio en POST de mutación financiera; almacenado 24h en Redis.
ErroresRFC 7807 (application/problem+json) con code, detail, errors[] por campo.
Filtros?filter[status]=posted&filter[date_from]=...; whitelist por endpoint (Ransack acotado).
Rate limitrack-attack por company_id + IP; cuotas por plan SaaS.
AuthAuthorization: Bearer <JWT> (access 15 min) + refresh rotativo; scopes OAuth para integraciones.

6.2 Ejemplo de respuesta paginada

GET /api/v1/accounting/journal_entries?filter[status]=posted&cursor=eyJpZCI6MTAwfQ&limit=50
{
  "data": [{ "id": "101", "type": "journal_entry", "attributes": { "number": "00001234", "total_debit": "1180.00", "currency": "PEN" } }],
  "meta": { "next_cursor": "eyJpZCI6MTUwfQ", "has_more": true },
  "links": { "self": "/api/v1/accounting/journal_entries?...", "next": "/api/v1/accounting/journal_entries?cursor=eyJpZCI6MTUwfQ" }
}

7. Webhooks e integraciones salientes

ConTodo emite webhooks firmados a sistemas externos (e-commerce, OSE/SUNAT proxies, bancos) y consume integraciones entrantes.

IntegraciónTipoPatrón
Facturación electrónica (OSE)SalienteWebhook + cola dedicada con reintentos; estado por documento.
SIRE / PLE SUNATSaliente (batch)Job programado genera TXT/JSON desde vistas; ACL traduce a formato SUNAT.
Tipo de cambio SBS/SUNATEntranteCron Sidekiq diario → SharedKernel::ExchangeRate.
Pagos (Yape/Plin/Niubiz/Izipay)BidireccionalWebhook entrante firmado → Treasury::PaymentSettled.
Conciliación bancariaEntranteImportador de extractos (CSV/API Open Banking) → matching.

Anti-Corruption Layer (ACL): cada integración vive en un adapter dentro de su pack, traduciendo formatos externos al lenguaje ubicuo interno. Ningún modelo de dominio conoce el formato de SUNAT.


8. Infraestructura de cómputo y colas (AWS ECS)

Cola SidekiqPrioridadContenidoSLA
critical10Outbox relay, asientos contables, idempotencia.< 5 s
default5Webhooks, notificaciones, recálculo kardex.< 30 s
reports2EEFF, exportes PLE/SIRE, refresh vistas materializadas.minutos
ai1Forecast demanda, detección de anomalías.batch

9. Riesgos y mitigaciones

#RiesgoImpactoMitigación
R1Fuga de datos entre tenants por scope olvidado.Crítico (legal/reputacional).RLS obligatorio + test de aislamiento automatizado en CI por cada modelo Tenantable.
R2Eventos duplicados generan asientos contables dobles.Alto (descuadre fiscal).Consumidores idempotentes con idempotency_key = event_id y constraint único.
R3El modular monolith deriva en "big ball of mud".Medio (deuda técnica).packwerk en CI bloquea dependencias no declaradas; published APIs explícitas.
R4Outbox relay se retrasa (lag de eventos).Medio (reportes obsoletos).Métrica de lag + autoscaling de workers critical; alerta si now()-occurred_at > 60s.
R5Migraciones bloquean la tabla en multi-tenant single-DB.Alto (downtime).strong_migrations gem; cambios online (add_column sin default, backfill por lotes).
R6N+1 y locks en kardex valorizado bajo carga.Medio.CQRS: lecturas en read replica + vistas materializadas; escritura serializada por advisory lock de producto.
R7Acoplamiento a un broker (vendor lock-in).Bajo.EventBus como interfaz; implementaciones Redis (dev) / SNS-EventBridge (prod) intercambiables.

10. Oportunidades

#OportunidadValor
O1Outbox como changelog para alimentar BI/IA sin tocar las tablas operativas.Pipeline analítico desacoplado y reproducible.
O2Extracción quirúrgica a microservicio de un pack cuando un módulo (ej. IA/forecast) tenga perfil de escala distinto, sin reescritura.Escala selectiva, costo controlado.
O3API pública + webhooks habilitan un marketplace de integraciones (e-commerce, logística) y modelo de partners.Nueva línea de ingresos y efecto red.
O4RLS + auditoría inmutable posicionan a ConTodo para certificaciones (SOC 2, ISO 27001) exigibles a clientes medianos.Diferenciador comercial vs. competidores locales.
O5Idempotencia y eventos versionados permiten clientes offline-first (sucursales con conexión intermitente) que sincronizan sin duplicar.Ventaja en retail/distribución regional.

11. Roadmap técnico incremental

  1. Fase 0 — Cimientos: SharedKernel (tenancy, RLS, Money, EventBus), IAM, Organization, esqueleto API v1, CI con packwerk + strong_migrations.
  2. Fase 1 — Núcleo operativo: Inventory/Kardex, Purchasing, Sales con outbox y primeros subscribers contables.
  3. Fase 2 — Núcleo financiero: Accounting (PCGE), Treasury, integraciones SUNAT (SIRE/PLE) y facturación electrónica.
  4. Fase 3 — Verticales: Manufacturing, Imports, Logistics; Payroll/RRHH.
  5. Fase 4 — Inteligencia: BI (vistas materializadas + read replica), IA (forecast/anomalías) consumiendo el changelog del outbox.

Conclusión. La combinación modular monolith + DDD + outbox transaccional + RLS da a ConTodo la integridad contable que un ERP exige, el aislamiento multi-tenant que un SaaS demanda, y una ruta de evolución selectiva a microservicios sin pagar su complejidad por adelantado.