Análisis de Normalización del Modelo Relacional — ExpressMX
Documento académico que evalúa el cumplimiento de las formas normales 1NF, 2NF, 3NF y BCNF en el modelo de datos de ExpressMX, ilustrando cada nivel con ejemplos concretos extraídos del esquema implementado en PostgreSQL (Neon).
Fuente: docs/architecture/schema-consolidated.sql.
Generado: 2026-05-05.
1. Marco teórico
La normalización es el proceso formal mediante el cual un modelo relacional se reorganiza para minimizar redundancia y prevenir anomalías de inserción, actualización y eliminación. Edgar F. Codd definió las primeras tres formas normales (1NF, 2NF, 3NF) y, junto con Raymond F. Boyce, la forma normal de Boyce-Codd (BCNF), que constituye el objetivo de diseño habitual para un sistema OLTP de mediano y alto volumen, como el caso que nos ocupa.
Cada nivel se construye sobre el anterior. En el contexto de ExpressMX, el equipo de diseño tomó como meta práctica alcanzar 3NF en todas las relaciones del núcleo transaccional y BCNF en las relaciones asociativas y de catálogo. Cuando la denormalización es deliberada (por ejemplo, contadores agregados que se actualizan vía triggers), se documenta explícitamente y se justifica con un argumento de rendimiento o consistencia eventual.
A continuación se describe cada forma normal, se indica la regla y se ofrece al menos un ejemplo concreto del esquema que la cumple. Posteriormente se analizan las decisiones de denormalización deliberada y se concluye con un dictamen agregado.
2. Primera Forma Normal (1NF)
2.1 Definición
Una relación está en 1NF cuando todas sus columnas son atómicas (un solo valor por celda), no existen grupos repetitivos y todas las filas son distinguibles por una clave primaria. Codd lo formaliza como ausencia de atributos compuestos, multivaluados o colecciones embebidas dentro de una columna.
En PostgreSQL existen tipos compuestos (JSONB, arrays) que pueden tensar esta definición. El criterio aplicado en ExpressMX es estricto cuando la información debe ser consultable con SQL relacional, y permisivo cuando se trata de configuración opaca o vectores de búsqueda donde la atomicidad lógica reside en la propia operación que los consume.
2.2 Ejemplo: tabla direcciones
La tabla direcciones ilustra la 1NF de manera canónica. Cada componente del domicilio mexicano se almacena en una columna atómica, en lugar de concentrarse en un único campo direccion_completa:
| Columna | Tipo | Atomicidad |
|---|---|---|
calle |
TEXT | atómica |
numero_ext |
TEXT | atómica |
numero_int |
TEXT | atómica |
colonia |
TEXT | atómica |
cp |
TEXT | atómica |
ciudad |
TEXT | atómica |
estado |
TEXT | atómica |
latitud |
NUMERIC(10,7) | atómica |
longitud |
NUMERIC(10,7) | atómica |
referencia |
TEXT | atómica (texto libre, no estructurado pero no compuesto) |
Cada fila se identifica por una PK de tipo UUID (id) y por la dependencia funcional id → {usuario_id, calle, numero_ext, colonia, cp, ...}. No hay grupos repetitivos: si un usuario tiene varias direcciones, cada una vive en su propia fila ligada por usuario_id (relación 1:N). Por tanto, direcciones cumple 1NF.
Un segundo ejemplo intencionado es disponibilidad_prestador. Dado que un prestador puede tener varios bloques horarios, en lugar de almacenar una lista en una sola columna se modeló como una tabla separada con una fila por bloque (prestador_id, dia, hora_inicio, hora_fin). Esto preserva 1NF y habilita consultas como "qué prestadores están disponibles los martes entre 10:00 y 12:00" sin parsing manual.
2.3 Excepciones controladas a 1NF
ExpressMX usa tres campos de tipo no escalar:
kb_articles.tipo_aplicaykb_articles.audiencia(tipo_ticket[]yaudiencia_kb[]): listas pequeñas y acotadas; consultadas con índices GIN. La justificación es que estos atributos pertenecen al artículo como un todo y no admiten relaciones adicionales (no necesitan FK a otras tablas).escalation_log_ticket.kb_consultados(UUID[]): lista corta de IDs de KB consultados durante el escalamiento. Es snapshot y no entidad propia.zonas_cobertura.poligono_coordsysla_calendars.config_jsonb(JSONB): estructuras opacas usadas íntegramente por la lógica de aplicación. Aplicar 1NF estricta exigiría tablas geometría/calendario costosas y sin valor real adicional.
Estas decisiones se mantienen por argumento de coste-beneficio y se consideran apartamientos justificados, no violaciones.
3. Segunda Forma Normal (2NF)
3.1 Definición
Una relación está en 2NF si está en 1NF y todo atributo que no forma parte de la clave primaria depende funcionalmente de la clave completa, no de un subconjunto de la misma. La 2NF sólo aplica a relaciones con clave primaria compuesta, ya que con PK simple es trivialmente cierta.
3.2 Ejemplo: tabla pagos
Aunque pagos tiene PK simple (id), conviene examinar la dependencia 2NF en su forma más pura. La PK es id y todos los atributos no clave (orden_id, monto, metodo, estatus, referencia_pasarela, created_at) dependen funcionalmente del id completo. No existe un subconjunto de la PK del que pudiera depender parcialmente cualquier columna; la regla se cumple por construcción.
3.3 Ejemplo con PK compuesta: roles_permisos
roles_permisos posee tanto una PK sintética (id) como una UNIQUE (rol_id, permiso_id). Si tomamos la candidata (rol_id, permiso_id) como referencia para evaluar 2NF, comprobamos que no existen atributos no clave fuera de la propia clave (id es PK sintética; las columnas semánticas son únicamente rol_id y permiso_id). Por tanto no hay dependencia parcial posible. La relación está en 2NF.
Otro ejemplo más rico es ticket_major_incident_link, cuya PK es compuesta (ticket_id, major_incident_id). Los atributos no clave son vinculado_at y vinculado_por. Ambos dependen del par completo, no de cada componente por separado: la fecha y el responsable corresponden al evento de vinculación entre ese ticket y ese incidente, no a uno solo de ellos. La relación cumple 2NF.
4. Tercera Forma Normal (3NF)
4.1 Definición
Una relación está en 3NF si está en 2NF y no presenta dependencias transitivas: un atributo no clave no debe depender funcionalmente de otro atributo no clave. Equivalentemente, todo atributo no clave debe depender directamente de la clave (no a través de otro atributo).
La 3NF previene anomalías clásicas: si una columna se calcula a partir de otra, mantenerlas juntas obliga a actualizar dos lugares y abre la puerta a inconsistencias.
4.2 Ejemplo: tabla ordenes_servicio
Examinando ordenes_servicio, los atributos no clave son cliente_id, prestador_id, servicio_id, direccion_id, cupon_id, estatus, fecha_programada, notas_cliente, notas_prestador, monto_total, descuento, pin_cliente, pin_prestador, created_at, updated_at.
Una posible violación de 3NF habría sido almacenar el nombre y precio del servicio dentro de ordenes_servicio. El precio depende del servicio_id, no de la orden, y duplicarlo derivaría en inconsistencia si el catálogo cambia. El diseño correcto que se implementó es no almacenar nombre_servicio ni precio_servicio en la orden: ambos se obtienen mediante JOIN con servicios. Lo único que se persiste por valor es monto_total y descuento, que son cálculos finales del cobro y no se pueden recomputar fielmente con el catálogo actual (los precios pueden variar tras la orden). Por tanto, esa información se justifica como snapshot inmutable y no como dependencia transitiva.
Un caso simétrico es la dirección. ordenes_servicio.direccion_id apunta a direcciones y la calle/colonia/CP no se duplican en la orden; cualquier consulta resuelve la dirección por JOIN. La relación está en 3NF.
4.3 Ejemplo adicional: transacciones_prestador
transacciones_prestador enlaza orden, prestador y pago. Sus atributos no clave son monto_prestador, comision_plataforma, estatus_deposito, referencia_bancaria, created_at. Ninguno depende de otro atributo no clave: el monto y la comisión se determinan por la regla aplicada al momento del split, el estatus de depósito depende del registro mismo, no de otro atributo. El diseño no almacena el monto_total del pago (eso vive en pagos.monto) ni el nombre del prestador. La 3NF se mantiene.
5. Forma Normal de Boyce-Codd (BCNF)
5.1 Definición
Una relación está en BCNF si para toda dependencia funcional no trivial X → Y, X es una superclave. BCNF es estrictamente más fuerte que 3NF en presencia de claves candidatas múltiples y dependencias entre atributos clave. En la práctica, una tabla en 3NF que carece de claves candidatas solapadas suele estar también en BCNF.
5.2 Ejemplo: tabla roles_permisos
roles_permisos tiene dos candidatas: id (PK sintética) y (rol_id, permiso_id) (UNIQUE). No hay atributos no clave fuera de las propias claves, por lo que no existen dependencias funcionales no triviales que no tengan a una superclave del lado izquierdo. Cumple BCNF.
5.3 Ejemplo: tabla servicios_prestador
servicios_prestador tiene candidatas id y (prestador_id, servicio_id). El único atributo no clave que podría introducir una dependencia con riesgo es precio_custom, pero éste depende del par (prestador_id, servicio_id), que es superclave (UNIQUE). Por tanto la dependencia (prestador_id, servicio_id) → precio_custom no viola BCNF: el lado izquierdo es superclave. Lo mismo ocurre con activo y created_at. La relación está en BCNF.
5.4 Ejemplo: tabla ticket_major_incident_link
Esta relación asociativa tiene PK compuesta (ticket_id, major_incident_id) y dos atributos no clave (vinculado_at, vinculado_por) que dependen del par completo. La única candidata es la PK; toda dependencia funcional satisface el criterio BCNF (lado izquierdo superclave). Cumple BCNF.
5.5 Ejemplo adicional: csat_surveys
csat_surveys tiene PK id y un índice UNIQUE sobre ticket_id, lo que la convierte en candidata. Las dependencias id → todos, y ticket_id → todos son ambas con superclave del lado izquierdo. Las columnas como csat_score, ces_score, comentario, respondido_at dependen del registro completo (es decir, del ticket sobre el que se hizo la encuesta) y no introducen dependencias entre sí. Cumple BCNF.
6. Casos de denormalización deliberada
La normalización no es un fin en sí mismo. Cuando un atributo derivado tiene cardinalidad alta de lectura y se actualiza con un patrón controlado, el equipo de diseño puede mantener un cache columnar dentro de la entidad y sostenerlo con triggers o jobs idempotentes. Estos casos no son violaciones, sino decisiones documentadas con justificación de rendimiento.
6.1 kb_articles.view_count, helpful_count, not_helpful_count
Estos contadores son agregaciones de kb_views y de eventos de retroalimentación. Mantenerlos en la entidad evita un COUNT(*) por cada renderizado de la KB pública. La consistencia se preserva mediante un trigger que incrementa el contador al insertar la vista; el CHECK (view_count >= 0) defiende la invariante. Si se desea reconciliar, basta un job que ejecute UPDATE kb_articles SET view_count = (SELECT COUNT(*) FROM kb_views WHERE article_id = kb_articles.id).
Justificación: el ratio de lectura es órdenes de magnitud mayor al de escritura, y el costo de calcular el agregado en cada SELECT es alto.
6.2 cortes_pago.monto_total, num_transacciones
cortes_pago resume un conjunto de filas en transacciones_prestador. El monto_total y num_transacciones son agregaciones que la lógica de cierre de corte calcula y persiste. Justificación: el corte se cierra una vez al período (típicamente quincenal) y se consulta muchas veces (panel del prestador, panel finanzas, reportes). Recalcular la suma cada vez sería ineficiente y el dato es inmutable después del cierre.
6.3 tickets_soporte.cerrado_at, cerrado_por
Estos campos son denormalización temporal: la información existe en historial_estatus_orden-equivalente para tickets, pero se cachea en la cabecera para acelerar listados que muestran "tickets cerrados en los últimos 7 días" sin necesidad de unir con el log de estatus.
6.4 canned_responses.uso_count
Contador agregado de uso. Mismo patrón: lectura frecuente, escritura barata vía trigger o función SECURITY DEFINER.
6.5 Columnas generadas (STORED)
ticket_sla_state.breached_frt y breached_ttr son columnas GENERATED ALWAYS AS (...) STORED. Aquí la denormalización es estructural: PostgreSQL garantiza la consistencia de la columna a partir de las otras (primer_respuesta_at > frt_due_at). Conceptualmente sigue siendo dependencia derivada, pero el motor evita la inconsistencia. El mismo patrón se aplica a kb_articles.search_vector, que es un tsvector derivado de titulo, resumen y contenido_md con pesos A, B y C.
6.6 Snapshot de datos en ordenes_servicio.monto_total
Como se discutió en 4.2, monto_total es snapshot inmutable del cobro al momento de la orden. No es dependencia transitiva propiamente dicha: aunque hay relación con el catálogo, los precios pueden cambiar después y el monto histórico debe preservarse. Es un caso clásico de denormalización temporal que el equipo de diseño aceptó conscientemente.
6.7 Resumen de denormalizaciones
| Atributo | Tabla | Patrón | Mecanismo |
|---|---|---|---|
view_count |
kb_articles |
contador agregado | trigger kb_log_view |
helpful_count, not_helpful_count |
kb_articles |
contadores agregados | trigger sobre kb_views |
monto_total |
cortes_pago |
suma agregada | función de cierre de corte |
num_transacciones |
cortes_pago |
conteo | función de cierre de corte |
cerrado_at, cerrado_por |
tickets_soporte |
snapshot temporal | aplicación al cerrar |
uso_count |
canned_responses |
contador | trigger al usar la plantilla |
breached_frt, breached_ttr |
ticket_sla_state |
columna generada | GENERATED ALWAYS AS ... STORED |
search_vector |
kb_articles |
índice de búsqueda derivado | GENERATED ALWAYS AS ... STORED |
monto_total |
ordenes_servicio |
snapshot inmutable | aplicación al crear la orden |
intentos_fallidos |
pins_soporte |
contador de seguridad | función verificar_pin |
7. Análisis cualitativo por dominio
7.1 Identidad y RBAC
Las tablas usuarios, usuarios_admin, roles_admin, permisos, roles_permisos cumplen 3NF. Las asociativas roles_permisos y usuarios_admin están además en BCNF. Cabe destacar que usuarios mantiene una FK 1:1 contra auth.users, lo que separa la información de identidad sensible (gestionada por Supabase) de los atributos del perfil aplicativo.
7.2 Catálogo de servicios
categorias_servicio (PK id, UK efectiva sobre nombre cuando se requiere unicidad operativa), servicios y servicios_prestador están en 3NF; servicios_prestador está en BCNF.
7.3 Geografía y agencias
zonas_cobertura, agencias, tarifas_zona cumplen 3NF. tarifas_zona se diseñó deliberadamente sin denormalizar el nombre del servicio o de la zona, evitando una posible violación de 3NF.
7.4 Marketing
cupones, banners_promocionales, config_sistema cumplen 3NF. cupones.usos_actuales es contador denormalizado con CHECK (usos_actuales <= usos_maximos) que defiende la invariante de capacidad.
7.5 Onboarding
invitaciones_prestadores, documentos_prestador, disponibilidad_prestador cumplen 3NF. direcciones cumple 1NF/2NF/3NF y es ejemplo canónico de atomicidad.
7.6 Órdenes y operación
ordenes_servicio cumple 3NF. historial_estatus_orden, evidencias_orden, cargos_extra_orden cumplen 3NF. La tabla historial_estatus_orden documenta cambios de estatus inmutables, y por su naturaleza append-only no presenta riesgo de inconsistencia.
7.7 Pagos y finanzas
pagos, transacciones_prestador, cortes_pago, facturas cumplen 3NF. cortes_pago denormaliza explícitamente monto_total y num_transacciones por rendimiento.
7.8 Calificaciones y notificaciones
calificaciones (UNIQUE (orden_id, calificador_id)) cumple BCNF. notificaciones cumple 3NF. Ningún campo se desplaza fuera de su dependencia natural a la PK.
7.9 Soporte v1 y v2 (ITSM)
tickets_soporte, mensajes_ticket, reembolsos, escalation_log_ticket, sla_calendars, sla_policies, ticket_sla_state, kb_articles, kb_article_history, kb_views, csat_surveys, major_incidents, major_incident_updates, ticket_major_incident_link, canned_responses cumplen 3NF. Las asociativas (ticket_major_incident_link) y aquellas con UNIQUE secundario fuerte (csat_surveys.ticket_id, kb_articles.slug, canned_responses.slug) cumplen BCNF.
7.10 Break-glass / Zero Trust
pins_soporte, accesos_perfil_usuario, pins_soporte_cliente, casos_soporte_abiertos cumplen 3NF. pins_soporte es ejemplo de relación 1:1 con usuarios por PK compartida, lo que estrictamente forma parte del patrón 6NF (descomposición temporal/atómica), pero a nivel formal es 3NF/BCNF.
7.11 Auditoría
logs_auditoria cumple 3NF. Es una tabla append-only con dependencias unidireccionales claras desde id a todo lo demás. Los snapshots valor_anterior y valor_nuevo (JSONB) se consideran apartamiento controlado de 1NF estricta, aceptado como bitácora forense.
8. Conclusión
El esquema relacional de ExpressMX cumple, como mínimo, la tercera forma normal en todas las relaciones del núcleo transaccional. Las tablas asociativas (n:m), aquellas con identificadores naturales únicos (slugs, ticket_id en CSAT) y las relaciones 1:1 cumplen además la forma normal de Boyce-Codd. Las decisiones de denormalización son explícitas, están documentadas y se sostienen mediante triggers, columnas generadas o funciones idempotentes, con CHECK de invariantes en los lugares donde la consistencia podría romperse.
El diseño resultante balancea formalismo con pragmatismo: prioriza la prevención de anomalías en el dominio operativo (órdenes, pagos, tickets) y permite agregaciones cacheadas en lecturas calientes (KB, cortes, CSAT), respaldadas por mecanismos de reconciliación. Esta postura es compatible con la literatura clásica de bases de datos relacionales y con las prácticas modernas de sistemas OLTP de mediano y alto volumen.
Fuentes consultadas
- Codd, E. F. (1972). "Further Normalization of the Data Base Relational Model". IBM Research Report RJ909.
- Date, C. J. (2003). "An Introduction to Database Systems" (8.ª edición). Addison-Wesley.
- Elmasri, R., Navathe, S. (2016). "Fundamentals of Database Systems" (7.ª edición). Pearson.
expressmx/docs/architecture/schema-consolidated.sql.expressmx/00.sql.expressmx/migrations/001-006_*.sql.expressmx/.planning/research/case-study-validation-report.md.