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 / Supuesto | Justificación | Alternativa descartada |
|---|---|---|---|
| A1 | Modular 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. |
| A2 | Rails 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. |
| A3 | Multi-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. |
| A4 | Eventos 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. |
| A5 | CQRS 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. |
| A6 | API 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. |
| A7 | Autorizació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):
SharedKernelno 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).
| Capa | Mecanismo de aislamiento | Falla segura |
|---|---|---|
| HTTP | Middleware resuelve tenant; rechaza request sin company_id. | 401/403 |
| ORM | Tenantable 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) |
| Background | TenantSetter.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;
| Atributo | Garantía |
|---|---|
| Entrega | At-least-once → consumidores idempotentes por event_id. |
| Orden | Por agregado (no global); el relay procesa FIFO por id con SKIP LOCKED. |
| Versionado | schema_ver permite evolución de payload sin romper consumidores antiguos. |
| Reproceso | outbox:replay re-publica rango por fecha para reconstruir read models de BI. |
6. Diseño de APIs REST
6.1 Convenciones
| Aspecto | Decisión |
|---|---|
| Versionado | URL path /api/v1. Deprecación con header Sunset y 6 meses de solape. |
| Paginación | Cursor (?cursor=...&limit=50) para listados grandes (kardex, asientos); offset para catálogos pequeños. Metadatos en meta. |
| Idempotencia | Header Idempotency-Key obligatorio en POST de mutación financiera; almacenado 24h en Redis. |
| Errores | RFC 7807 (application/problem+json) con code, detail, errors[] por campo. |
| Filtros | ?filter[status]=posted&filter[date_from]=...; whitelist por endpoint (Ransack acotado). |
| Rate limit | rack-attack por company_id + IP; cuotas por plan SaaS. |
| Auth | Authorization: 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ón | Tipo | Patrón |
|---|---|---|
| Facturación electrónica (OSE) | Saliente | Webhook + cola dedicada con reintentos; estado por documento. |
| SIRE / PLE SUNAT | Saliente (batch) | Job programado genera TXT/JSON desde vistas; ACL traduce a formato SUNAT. |
| Tipo de cambio SBS/SUNAT | Entrante | Cron Sidekiq diario → SharedKernel::ExchangeRate. |
| Pagos (Yape/Plin/Niubiz/Izipay) | Bidireccional | Webhook entrante firmado → Treasury::PaymentSettled. |
| Conciliación bancaria | Entrante | Importador 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 Sidekiq | Prioridad | Contenido | SLA |
|---|---|---|---|
critical | 10 | Outbox relay, asientos contables, idempotencia. | < 5 s |
default | 5 | Webhooks, notificaciones, recálculo kardex. | < 30 s |
reports | 2 | EEFF, exportes PLE/SIRE, refresh vistas materializadas. | minutos |
ai | 1 | Forecast demanda, detección de anomalías. | batch |
9. Riesgos y mitigaciones
| # | Riesgo | Impacto | Mitigación |
|---|---|---|---|
| R1 | Fuga de datos entre tenants por scope olvidado. | Crítico (legal/reputacional). | RLS obligatorio + test de aislamiento automatizado en CI por cada modelo Tenantable. |
| R2 | Eventos duplicados generan asientos contables dobles. | Alto (descuadre fiscal). | Consumidores idempotentes con idempotency_key = event_id y constraint único. |
| R3 | El modular monolith deriva en "big ball of mud". | Medio (deuda técnica). | packwerk en CI bloquea dependencias no declaradas; published APIs explícitas. |
| R4 | Outbox relay se retrasa (lag de eventos). | Medio (reportes obsoletos). | Métrica de lag + autoscaling de workers critical; alerta si now()-occurred_at > 60s. |
| R5 | Migraciones bloquean la tabla en multi-tenant single-DB. | Alto (downtime). | strong_migrations gem; cambios online (add_column sin default, backfill por lotes). |
| R6 | N+1 y locks en kardex valorizado bajo carga. | Medio. | CQRS: lecturas en read replica + vistas materializadas; escritura serializada por advisory lock de producto. |
| R7 | Acoplamiento a un broker (vendor lock-in). | Bajo. | EventBus como interfaz; implementaciones Redis (dev) / SNS-EventBridge (prod) intercambiables. |
10. Oportunidades
| # | Oportunidad | Valor |
|---|---|---|
| O1 | Outbox como changelog para alimentar BI/IA sin tocar las tablas operativas. | Pipeline analítico desacoplado y reproducible. |
| O2 | Extracció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. |
| O3 | API pública + webhooks habilitan un marketplace de integraciones (e-commerce, logística) y modelo de partners. | Nueva línea de ingresos y efecto red. |
| O4 | RLS + auditoría inmutable posicionan a ConTodo para certificaciones (SOC 2, ISO 27001) exigibles a clientes medianos. | Diferenciador comercial vs. competidores locales. |
| O5 | Idempotencia 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
- Fase 0 — Cimientos: SharedKernel (tenancy, RLS, Money, EventBus), IAM, Organization, esqueleto API v1, CI con packwerk + strong_migrations.
- Fase 1 — Núcleo operativo: Inventory/Kardex, Purchasing, Sales con outbox y primeros subscribers contables.
- Fase 2 — Núcleo financiero: Accounting (PCGE), Treasury, integraciones SUNAT (SIRE/PLE) y facturación electrónica.
- Fase 3 — Verticales: Manufacturing, Imports, Logistics; Payroll/RRHH.
- 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.