ConTodo
Consultor Senior de Arquitectura de Software (entregable consolidado)

ConTodo ERP — Arquitectura de Software: Bounded Contexts (DDD), Event-Driven, API-First y Patrones

ConTodo ERP — Arquitectura de Software

Propósito. Este entregable consolida y reconcilia las decisiones de arquitectura de software de ConTodo, el ERP SaaS multi-tenant cloud-native para PYMEs y medianas empresas de Perú y LATAM (textiles, importadoras, comercializadoras, distribuidoras y manufactura ligera). A diferencia de los documentos por especialista, este artefacto es vinculante: fija el contrato de bounded contexts (DDD), el modelo event-driven (eventos + outbox), la estrategia API-first (REST + GraphQL + webhooks + API Gateway + marketplace) y los patrones tácticos que el equipo debe respetar. Resuelve además las contradicciones inter-documento que el Partner de Tecnología detectó en la revisión crítica (debate-02): nombre y tipo de tenant_id, número de buses de eventos, y la frontera entre outbox y CDC.

Anti-overclaiming. Ninguna arquitectura es "la correcta" en abstracto. Las decisiones aquí seleccionan el punto que mejor equilibra el perfil real de ConTodo —muchos tenants PYME pequeños, integridad contable ACID, compliance SUNAT, equipo de 4–6 ingenieros— sobre alternativas igualmente válidas para otros perfiles. Cada decisión declara la alternativa descartada y el disparador que la reabriría.


1. Resumen ejecutivo de decisiones (ADR maestro)

Este documento absorbe el mandato del debate técnico de producir un único contrato de decisiones. Las contradicciones identificadas (C1–C8 del debate-02) se cierran aquí:

ADRDecisión vinculanteAlternativa descartadaDisparador de reapertura
ADR-01Modular Monolith sobre Rails 7.2, organizado en bounded contexts con packwerk (un pack por context).Microservicios desde día 1 (sagas distribuidas, latencia, complejidad 5x).Un context con perfil de escala radicalmente distinto (ej. IA/forecast) y dolor real de despliegue acoplado → extracción quirúrgica.
ADR-02Tenant = tenant_id BIGINT interno (PK/FK, sharding) + UUID público para identificadores expuestos en API. Nombre canónico: tenant_id (no company_id).company_id; UUID como PK interna (índices más pesados, sharding más caro).Ninguno previsto; es decisión de día 1. (Cierra C2 y C3 del debate.)
ADR-03Shared Schema + Row-Level Security (RLS) como tenancy primario; Silo on-demand (connects_to Rails) para enterprise.Schema-per-tenant (techo ~5.000 schemas, migraciones O(N)); DB-per-tenant para todos (costo).Tenant enterprise exige residencia/aislamiento físico → graduación a Silo.
ADR-04Un solo bus de integración: Outbox transaccional → relay Sidekiq → Amazon EventBridge. CDC/Kafka diferido a Fase 3 (solo si BI exige latencia <5 min).Tres sistemas en paralelo (Redis/Sidekiq + SNS/EventBridge + Kafka/MSK).Necesidad medida de streaming BI sub-5-min a escala → introducir CDC, no antes. (Cierra C4, C6, RT3, RT4.)
ADR-05API-first híbrida: REST /api/v1 (JSON:API-like) como contrato canónico para SUNAT/bancos/UI; GraphQL como gateway de lectura agregada (BFF) para dashboards/BI a partir de Fase 2; webhooks firmados salientes; API Gateway + marketplace de integraciones.GraphQL como entrada única (N+1, autorización por campo, complejidad fiscal).
ADR-06CQRS ligero: comandos vía service objects/interactors; lecturas pesadas vía vistas materializadas + read replicas.Event Sourcing completo (sobre-ingeniería v1).Módulo de auditoría forense que justifique event sourcing acotado.
ADR-07Pooler + RLS validado con SET LOCAL dentro de transacción explícita, probado a través de RDS Proxy en CI.SET de sesión sin transacción (riesgo de fuga cross-tenant vía multiplexado del pooler).— (es bloqueante de seguridad; ver §4.4 y §9-RT1.)

Decisión de gobierno: este documento es el ADR-MASTER. Cualquier desviación requiere un ADR nuevo aprobado por el Solution Architect + CTO, no una decisión local de un equipo.


2. Vista C4 — Contexto (Nivel 1)


3. Mapa de Bounded Contexts (DDD)

El producto se descompone en bounded contexts alineados a los módulos del negocio. Cada context tiene su propio lenguaje ubicuo, sus agregados, sus eventos de dominio y una Published API explícita (Sales::PublicApi). La comunicación por defecto es asíncrona por eventos; las invariantes que exigen consistencia ACID se resuelven in-process dentro de una sola transacción (marcadas como síncronas).

3.1 Diagrama de contexts y flujo de eventos

3.2 Catálogo de bounded contexts

ContextLenguaje ubicuo (términos)Agregados raízEventos publicados claveRelación de dominio
IdentityAccessUsuario, Rol, Permiso, Membership, SesiónUser, Role, MembershipUserInvited, RoleAssignedUpstream conformista de todos (síncrono).
OrganizationEmpresa (RUC), Sucursal, Almacén, ParámetroCompany, Branch, WarehouseBranchCreated, SettingsChangedShared kernel operativo (síncrono).
Inventory/KardexProducto, StockItem, Lote, Capa de costo, Promedio móvilKardexLedger, ProductStockValued, StockReserved, StockAdjustedCustomer/Supplier de Sales, Purchasing, Manufacturing, Imports.
PurchasingOC, Recepción, Factura proveedor, DetracciónPurchaseOrder, GoodsReceiptGoodsReceived, InvoiceBookedSupplier de Inventory y Accounting.
SalesCotización, Nota de venta, CPE, NC/NDSalesOrder, InvoiceSaleConfirmed, CreditNoteIssuedSupplier de Inventory, Accounting, Treasury, CRM.
ManufacturingBOM multinivel, OP, WIP, merma, CIFWorkOrder, BillOfMaterialsMaterialConsumed, FGProduced, WIPPostedCustomer de Inventory; Supplier de Accounting.
LogisticsDespacho, Guía de Remisión (GRE), MTCDispatch, RemissionGuideDispatched, GRESentSupplier de Sales.
ImportsDUA/DAM, FOB, CIF, landed cost, agente de aduanaImportFileLandedCostAllocated, ImportNationalizedSupplier de Inventory y Accounting.
AccountingPCGE, Asiento, Partida doble, Plantilla, PeriodoJournalEntry, Account, PostingTemplateEntryPosted, PeriodClosedCustomer (vía subscribers) de casi todos; Supplier de Treasury y FinancialStatements.
TreasuryBanco, CxC, CxP, Detracción, ConciliaciónBankAccount, Receivable, PayablePaymentSettled, ReconciledCustomer de Accounting y Sales.
FinancialStatementsEEFF, PLE, SIRE, RVIE, RCEStatementRun, SireSubmissionSireGenerated, PleExportedCustomer de Accounting y Treasury.
HumanResourcesLegajo, Contrato, TrabajadorEmployeeEmployeeHiredSupplier de Payroll.
PayrollPlanilla, AFP/ONP, PLAME, CTSPayrollRunPayrollPostedSupplier de Accounting.
CRMCliente, Pipeline, ActividadCustomer, OpportunityOpportunityWonCustomer de Sales.
BusinessIntelligenceDashboard, KPI, Métrica, Cube modelDashboard (read models)— (consumidor)Downstream consumidor de eventos.
AICopiloto, Forecast, Anomalía, Token meterForecastModel, ConversationAnomalyDetectedDownstream; habla con Cube (capa semántica), no SQL ad-hoc.

3.3 Reglas de dependencia (enforced por packwerk en CI)

  • SharedKernel no depende de nadie; todos dependen de él.
  • Operaciones y Finanzas nunca se referencian por clase directamente — solo por eventos o por PublicApi explícita.
  • IdentityAccess y Organization son las únicas dependencias síncronas permitidas en cualquier request (tenant y usuario están en todo flujo).
  • Un context fail-closed: una operación sin tenant_id resuelto aborta (no asume default).
  • El context map sigue patrones DDD de relación: la mayoría son Customer/Supplier vía eventos; las integraciones externas (SUNAT, bancos) usan Anti-Corruption Layer (ACL) dentro del pack que las consume, de modo que ningún modelo de dominio conoce el formato UBL/SUNAT.

4. Arquitectura interna por capas (patrones tácticos)

4.1 Anatomía de un bounded context

PatrónAplicación en ConTodoJustificación
Aggregate / Aggregate RootKardexLedger, JournalEntry, WorkOrder encapsulan invariantes (partida doble cuadrada, stock no negativo, BOM válido).Las invariantes contables/inventario no pueden vivir dispersas en controllers.
Command / InteractorUn caso de uso = un service object (Sales::ConfirmSale, Accounting::PostJournalEntry).Testeable unitariamente, idempotente, transaccional.
Domain EventCambio de estado relevante para otros contexts.Desacopla operación de contabilidad/BI.
OutboxEl evento se persiste en la misma transacción que el cambio de estado.Evita dual-write inconsistente (DB commit + broker).
Subscriber / Policy de posteoAccounting::OnSaleConfirmed mapea evento → asiento vía PostingTemplate.Las plantillas contables son editables por el contador, versionadas, con dry-run y auditoría (foso vs CONCAR/SISCONT).
Anti-Corruption LayerAdapters SUNAT/OSE/bancos traducen formatos externos al lenguaje ubicuo.Aísla el dominio de cambios regulatorios externos.
CQRS ligeroEscritura por comandos; lectura de EEFF/kardex valorizado por vistas materializadas en read replica.Reportes no compiten por locks con la operación transaccional.
Idempotency KeyHeader Idempotency-Key en POST financieros; event_id como clave en subscribers.Evita asientos duplicados ante reintentos at-least-once.

4.2 Multi-tenancy — defensa en profundidad

CapaMecanismoFalla segura
HTTPMiddleware resuelve tenant; rechaza request sin tenant.401/403
AutorizaciónPundit policy por módulo × sucursal × acción (RBAC + ABAC + SoD).403
ORMTenantable default scope + asignación automática de tenant_id.Excepción si falta Current.tenant_id
TransacciónSET LOCAL app.current_tenant dentro de TX (no SET de sesión).
Motor (RLS)Policy USING (tenant_id = current_setting('app.current_tenant')::bigint).0 filas (no fuga)
BackgroundTenantSetter.with(args[:tenant_id]) envuelve cada job Sidekiq.Job aborta sin tenant

4.3 Particionado y escala de datos

  • Tablas calientes (comprobantes, kardex_movimientos, asientos_contables, outbox_events) particionadas por tenant_id (HASH) y/o fecha (RANGE) desde el MVP — contiene noisy-neighbor y acelera VACUUM.
  • Reconocido el riesgo de desbalance por power-law (el 5% de tenants genera ~50% de carga): el particionado por tenant_id no resuelve al tenant gigante dentro de su partición; la mitigación real es promoción a Silo con disparador explícito (CPU sostenido > 70% en RDS escalada al máximo, o > X% de IOPS atribuible a un solo tenant). Ese ADR de sharding/promoción debe existir antes de 500 tenants, con dueño (RACI).
  • Lectura BI/EEFF sobre read replicas vía connected_to(role: :reading).

4.4 [BLOQUEANTE] Pooler + RLS — el riesgo de seguridad #1

El debate técnico identificó como riesgo crítico (RT1) que RDS Proxy/PgBouncer en modo transaction multiplexa conexiones y puede filtrar el contexto de tenant entre requests si se usa SET de sesión. Mandato vinculante (ADR-07):

  1. Toda query debe correr dentro de transacción explícita con SET LOCAL app.current_tenant (scope transaccional, no de sesión).
  2. El pooler opera en modo transaction y nunca se confía una variable de sesión a través de checkouts.
  3. CI ejecuta un test de aislamiento cross-tenant que corre a través del pooler, no solo contra Postgres directo.
  4. Defensa adicional: validar/re-aplicar app.current_tenant tras cada checkout de conexión.

Sin esta validación, la promesa de aislamiento es papel y un incidente sería un evento reportable bajo Ley 29733.


5. Arquitectura Event-Driven (eventos + outbox)

5.1 Decisión consolidada: un solo bus

El debate detectó hasta tres sistemas de mensajería propuestos en paralelo (Sidekiq/Redis, SNS/EventBridge, Kafka/MSK). Se colapsan a uno (ADR-04):

Frontera outbox vs CDC (cierra RT3): en MVP y escala media, el outbox es la única fuente de eventos, y alimenta tanto integración (EventBridge → webhooks) como BI (batch nocturno dbt + DuckDB/S3). CDC/Debezium/Kafka no se introduce salvo necesidad medida de latencia BI sub-5-min a escala, lo que ahorra US$ 1.500–2.500/mes y enorme complejidad operativa temprana para un equipo de 4–6.

5.2 Flujo transaccional del outbox

5.3 Contrato del evento y garantías

CREATE TABLE outbox_events (
  id           BIGSERIAL,
  tenant_id    BIGINT NOT NULL,             -- canonico (ADR-02), particion HASH
  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,
  PRIMARY KEY (id, tenant_id)
) PARTITION BY HASH (tenant_id);
CREATE INDEX idx_outbox_unpublished ON outbox_events (id) WHERE published_at IS NULL;
AtributoGarantía
EntregaAt-least-once → consumidores idempotentes por event_id con constraint único.
OrdenPor agregado (no global); relay 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 BI.
LagMétrica + alerta si now() - occurred_at > 60s; autoscaling de workers critical.

5.4 Colas Sidekiq (con política Spot)

ColaPrioridadContenidoComputeSLA
critical10Outbox relay, asientos, idempotencia, emisión CPE/GRE.On-Demand (nunca Spot)< 5 s
default5Webhooks, notificaciones, recálculo kardex.Mixto< 30 s
reports2EEFF, exportes PLE/SIRE, refresh vistas materializadas.Spotminutos
ai1Forecast, detección de anomalías, OCR.Spotbatch

Política (cierra RT6/T4): la cola critical corre On-Demand explícito en Terraform — un Spot interrumpido a mitad de emisión de un CPE o un asiento es un riesgo fiscal inaceptable. Solo reports y ai usan Spot.


6. Arquitectura API-First

El producto se diseña API-first: la API es el producto, la UI es el primer cliente. Esto habilita el marketplace de integraciones y el modelo de partners.

6.1 Capas de la plataforma de APIs

6.2 REST — contrato canónico

AspectoDecisión
VersionadoURL path /api/v1. Deprecación con header Sunset + 6 meses de solape.
FormatoJSON:API-like; errores RFC 7807 (application/problem+json).
PaginaciónCursor (?cursor=...&limit=50) en listados grandes; offset en catálogos pequeños.
IdempotenciaIdempotency-Key obligatorio en POST de mutación financiera (almacenado 24h en Redis).
Filtros?filter[status]=posted con whitelist por endpoint (Ransack acotado).
Rate limitrack-attack por tenant_id + IP; cuotas por plan SaaS, reforzadas en API Gateway.
AuthAuthorization: Bearer <JWT> (access 15 min) + refresh rotativo; OAuth2 scopes para integraciones de terceros.
Fuente de verdadOpenAPI publicado por Rails (cierra el gap de ownership del spec): los tipos del frontend TS se generan desde OpenAPI; breaking changes pasan por proceso de versionado con dueño asignado.

6.3 GraphQL — gateway de lectura (BFF)

GraphQL no es la entrada única (evita N+1 y autorización por campo en flujos fiscales). Se introduce desde Fase 2 como gateway de lectura agregada para dashboards y BI, donde un cliente necesita componer datos de varios contexts en una sola llamada. La autorización reusa Pundit; la resolución de KPIs pasa por la capa semántica Cube (una sola verdad de KPI compartida con IA NL2SQL, evitando lógica duplicada).

6.4 Webhooks salientes

Emitidos desde el outbox/EventBridge, firmados HMAC-SHA256, con reintento exponencial y Dead Letter Queue. Cada webhook tiene estado por documento y panel de reentrega para el tenant.

IntegraciónTipoPatrón
Facturación electrónica (OSE)SalienteWebhook + cola dedicada con reintentos; estado por documento.
SIRE / PLE SUNATSaliente (batch)Job genera TXT/JSON desde vistas; ACL traduce a formato SUNAT.
Tipo de cambio SBS/SUNATEntranteCron diario → SharedKernel::ExchangeRate.
Pagos (Yape/Plin/Niubiz/Izipay)BidireccionalWebhook entrante firmado → Treasury::PaymentSettled.
E-commerce / MarketplaceBidireccionalWebhooks + REST sobre el marketplace.

6.5 API Gateway y Marketplace de integraciones

  • API Gateway gestiona API keys, planes/quotas por tier SaaS, throttling y métricas de consumo por partner — separando el control comercial de la lógica de negocio.
  • Marketplace de integraciones: catálogo de conectores (e-commerce, logística, pasarelas) construidos por partners sobre la API pública + webhooks + OAuth2 scopes. Es una línea de ingresos y un efecto red (oportunidad O3 del backend): cada integración nueva aumenta el valor de la plataforma.

7. Vista C4 — Componentes (Nivel 3)

Detalle del contenedor API ConTodo (Rails) mostrando los bounded contexts como componentes y sus relaciones de evento.

7.1 Coherencia de dominio reflejada en componentes (debate-03)

Tres incoherencias contables del debate de dominio se reflejan como requisitos de diseño de componentes que esta arquitectura debe soportar:

Requisito de dominio (P0)Impacto arquitectónico
Asiento de nacionalización desdoblado (proveedor exterior / Aduanas / agente).Imports publica ImportNationalized con payload de 3 contrapartidas; Accounting::OnImportNationalized genera asiento multi-acreedor con sustento DUA.
DUA/DAM (tipo doc 50) como sustento de crédito fiscal en RCE.El modelo de JournalEntry y el generador SIRE/RCE soportan tipo de documento de sustento aduanero, no solo factura.
Plantillas de producción WIP→FG→COGS (circuito clase 6→9→23→21→69).Manufacturing emite MaterialConsumed/WIPPosted/FGProduced; Accounting tiene PostingTemplate para cada uno → el costo del Kardex tiene espejo en el mayor.
IGV 16% + IPM 2% modelado interno, presentado 18%.SharedKernel parametriza tributos versionados; presentación consolidada es una vista, no una cuenta.
Detracciones por código de bien/servicio + umbral S/ 700.Tabla tabla_detracciones parametrizada y versionada; nunca 12% hardcodeado.

8. Patrones arquitectónicos transversales (resumen)

CategoríaPatrónDónde
EstructuraModular Monolith + Bounded Contextspackwerk packs
AislamientoShared Schema + RLS + Silo on-demandPostgreSQL / connects_to
MensajeríaOutbox transaccional + relay + EventBridgeOperaciones → Finanzas/BI
ConsistenciaACID in-process para invariantes; eventual para cross-contextComandos vs subscribers
Lectura/escrituraCQRS ligero (vistas materializadas, read replicas)BI, EEFF, kardex
FiabilidadIdempotency keys (HTTP + eventos), DLQ, retry exponencialPOST financieros, webhooks
IntegraciónAnti-Corruption LayerAdapters SUNAT/OSE/bancos
APIAPI-first (REST canónico + GraphQL BFF + webhooks + Gateway + marketplace)Edge/API
SeguridadDefensa en profundidad (WAF → Pundit → scope → SET LOCAL → RLS)Todas las capas
EvoluciónExtracción quirúrgica a microservicio bajo disparadorPack con perfil de escala propio
MigraciónExpand/contract + pg-osc + runbook (no rails db:migrate ingenuo a escala)DB ops

9. Riesgos arquitectónicos consolidados

IDRiesgoSeveridadMitigación
RT1Fuga cross-tenant vía connection pooler (RDS Proxy multiplexa SET).CríticaSET LOCAL en TX + test de aislamiento a través del pooler en CI (ADR-07, §4.4). Bloqueante de seguridad #1.
R2Eventos duplicados → asientos dobles.AltaConsumidores idempotentes con idempotency_key = event_id + constraint único.
RT3Doble pipeline de eventos (outbox + CDC) duplica carga WAL.AltaOutbox como única fuente; CDC diferido a Fase 3 (ADR-04).
RT4Tres sistemas de mensajería.AltaColapsado a Sidekiq (jobs) + EventBridge (integración); MSK solo si se justifica.
RT5Migración a escala bloquea a todos los tenants simultáneamente.AltaExpand/contract + pg-osc + runbook desde la primera tabla.
RT6Spot interrumpe jobs fiscales.Media-AltaCola critical On-Demand explícito en Terraform.
RT7Sharding/promoción a Silo sin disparador ni dueño.AltaADR de sharding con métrica gatillo + RACI antes de 500 tenants.
R3Modular monolith deriva en "big ball of mud".Mediapackwerk en CI bloquea dependencias no declaradas; PublicApi explícitas.
R4Outbox relay lag (reportes obsoletos).MediaMétrica de lag + autoscaling de critical + alerta > 60s.
RT10tenant_id UUID vs BIGINT inconsistente rompe RLS en runtime.MediaBIGINT interno + UUID público fijado día 1 (ADR-02).
R5Cadena costo importación→producción→venta no cierra en el mayor.Alta (dominio)Plantillas WIP/FG/COGS + asiento de nacionalización desdoblado + DUA en RCE (§7.1).

10. Oportunidades arquitectónicas

#OportunidadValor
O1Outbox como única fuente de eventos elimina MSK/Debezium en MVP.Ahorra US$ 1.500–2.500/mes y complejidad operativa temprana.
O2API pública + webhooks + marketplace habilita partners.Nueva línea de ingresos y efecto red.
O3Tier "Soberanía" (Silo + BYOK + Llama-VPC) como SKU enterprise.Diferenciador vs Defontana/StarSoft; mayor ticket.
O4Capa semántica Cube como contrato único de KPIs sirve a BI y a IA (NL2SQL).Menos alucinación, una sola verdad de negocio.
O5Tests de aislamiento cross-tenant en CI como evidencia auditable.Acelera SOC 2 / ISO 27001; activo de venta.
O6Extracción quirúrgica de packs a microservicio bajo demanda real.Escala selectiva sin reescritura.
O7Importador desde CONCAR/SISCONT vía ACL.Reduce el costo de cambio — principal obstáculo de adopción real.

11. Conclusión

La arquitectura de software de ConTodo combina modular monolith + DDD por bounded contexts + outbox transaccional + RLS multi-tenant + API-first, una composición deliberadamente probada y operable por un equipo pequeño, sin renunciar a la integridad contable ACID que un ERP fiscal exige. El valor de este entregable no está en las piezas —cada especialista ya las diseñó con criterio senior— sino en el ensamble reconciliado: fija un único tenant_id (BIGINT interno + UUID público), un único bus de eventos (outbox → EventBridge, con CDC diferido), una única estrategia de API (REST canónico + GraphQL BFF + webhooks + Gateway + marketplace) y, sobre todo, blinda el riesgo de aislamiento cross-tenant en el pooler que, de materializarse, sería un evento de extinción para un ERP contable. Las contradicciones inter-documento del debate técnico quedan cerradas como ADRs vinculantes, y las cinco incoherencias contables del debate de dominio se traducen en requisitos de diseño de componentes verificables. Con este contrato, la arquitectura pasa de "siete documentos excelentes que no negociaron entre sí" a un único plano ejecutable, listo para escribir la primera línea de código sin deuda de gobierno.