← Documentos

Análisis de normalización

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:

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.

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

↑ Volver arriba