Postgres para Tablas de Reglas y Migraciones con Liquibase
Las reglas que deciden guardrails, boosts y no-buys viven en Postgres porque son exactamente el tipo de dato que las bases de datos relacionales fueron diseñadas para proteger.
El motor de reglas que decide si una oferta recibe un tope de guardrail, un boost, o un no-buy directo, está impulsado enteramente por filas en un puñado de tablas de Postgres — `pricing_rule` a la cabeza. Antes de mirar el esquema, vale la pena ser explícitos sobre por qué este dato se gana una base de datos relacional y no, digamos, una colección de MongoDB sentada justo al lado del registro de auditoría que veremos dos lecciones más adelante. Tres propiedades lo empujan hacia allá: integridad referencial, transacciones multi-fila, y un patrón de consulta que es fundamentalmente sobre estructura — filtrar, unir y ordenar sobre columnas bien conocidas.
Integridad referencial primero. Una fila de `pricing_rule` no existe sola — aplica a una `vehicle_category` (sedán, SUV, camioneta), puede hacer referencia a un `provider_weighting`, y tiene una ventana `effective_from` / `effective_to`. Si una regla apuntara a una categoría de vehículo que fue borrada silenciosamente, el motor de reglas o fallaría al evaluar, o, peor, se saltaría en silencio un guardrail que nadie tuvo intención de eliminar. Una restricción de llave foránea hace que esa clase de bug sea estructuralmente imposible: Postgres rechaza el delete, o el insert, antes de que el dato malo llegue a existir. Un almacén documental puede aproximar esto con validaciones a nivel de aplicación, pero eso es verificar, no garantizar — la garantía vive a nivel de base de datos únicamente en un motor relacional.
Transacciones multi-fila, segundo. Cuando un analista publica un nuevo conjunto de reglas — digamos, endureciendo el guardrail sobre vehículos con título de salvamento mientras simultáneamente relaja el boost sobre camionetas de bajo kilometraje — esos dos cambios necesitan aterrizar juntos. Si el proceso muriera a la mitad, después de endurecer el guardrail pero antes de relajar el boost, el motor de reglas pasaría los siguientes minutos (o los siguientes miles de valuaciones) evaluando un conjunto de reglas que jamás existió como decisión de negocio deliberada. Envolver la actualización en un único límite `@Transactional` significa que Postgres garantiza que ambas filas cambian o ninguna lo hace — esto es precisamente lo que compran la 'atomicidad' y la 'consistencia' de ACID, y no es una propiedad que las transacciones multi-documento de MongoDB regalen con las mismas características de rendimiento, ni que Redis ofrezca en absoluto.
Patrón de consulta, tercero. La consulta real del motor de reglas no es 'dame un documento por su ID' — es 'dame cada regla activa para esta categoría de vehículo, ordenada por prioridad, donde la ventana de vigencia cubra hoy'. Eso es un `WHERE`, un `JOIN` contra `vehicle_category`, y un `ORDER BY`, sobre una tabla que, realísticamente, tiene unos pocos miles de filas incluso a escala. Postgres fue construido exactamente para esto: un planificador de consultas que puede elegir un índice, una tabla lo bastante pequeña como para vivir entera en memoria, y SQL que expresa el filtro de forma declarativa en lugar de forzar a la aplicación a reensamblarlo desde múltiples búsquedas.
Aquí es donde entra Liquibase, y resuelve un problema que no tiene nada que ver con qué base de datos elegiste y todo que ver con cómo un equipo cambia la forma de esa base de datos a lo largo del tiempo sin pisarse entre sí. Un script SQL ejecutado a mano — alguien haciendo SSH a un servidor y pegando `ALTER TABLE pricing_rule ADD COLUMN ...` — tiene tres modos de falla: no es repetible (¿recibió staging el mismo script que producción, en el mismo orden?), no es revisable (nadie lo puso en un pull request), y no es reversible de manera disciplinada (revertir significa que alguien recuerde el SQL inverso, bajo presión, durante un incidente).
Liquibase reemplaza los tres con un changelog: una lista ordenada y versionada de bloques `changeSet`, cada uno con un `id` y `author` únicos, cada uno aplicado exactamente una vez y rastreado en una tabla `DATABASECHANGELOG` que el propio Liquibase mantiene. Agregar una columna se convierte en un changeSet que se entrega en el mismo pull request que el código Kotlin que la lee, se revisa de la misma manera, corre de forma idéntica ya sea en tu laptop, en un pipeline de CI, o en producción — y, porque Liquibase calcula un checksum por changeSet, falla ruidosamente si alguien edita el historial después del hecho en lugar de agregar un nuevo changeSet encima.
La disciplina que esto compra es fácil de subestimar hasta que un rollback sale mal: porque cada changeSet es pequeño, aditivo, e identificado independientemente, `pricing_rule` puede ganar una columna `max_boost_percentage` hoy sin que nadie toque los doce changeSets anteriores, y si el changeSet de hoy resulta estar mal, se le puede pedir a Liquibase que revierta exactamente esa unidad de cambio — no 'restaurar desde el backup de anoche y esperar lo mejor'.
CREATE TABLE vehicle_category (id BIGSERIAL PRIMARY KEY,name VARCHAR(64) NOT NULL UNIQUE -- 'SEDAN', 'SUV', 'TRUCK');CREATE TABLE pricing_rule (id BIGSERIAL PRIMARY KEY,vehicle_category_id BIGINT NOT NULL REFERENCES vehicle_category(id),rule_type VARCHAR(32) NOT NULL, -- 'GUARDRAIL', 'BOOST', 'NO_BUY'adjustment_percentage NUMERIC(5,2),priority INT NOT NULL DEFAULT 0,effective_from DATE NOT NULL,effective_to DATE,CONSTRAINT chk_effective_window CHECK (effective_to IS NULL OR effective_to > effective_from));CREATE INDEX idx_pricing_rule_lookupON pricing_rule (vehicle_category_id, effective_from, effective_to);
This is illustrative only (runnable: false) — a schema sketch for pricing_rule showing the foreign key and effective-date window that make it a relational fit, not a runnable migration by itself.
<databaseChangeLogxmlns="http://www.liquibase.org/xml/ns/dbchangelog"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><changeSet id="2026-08-add-max-boost-percentage" author="c.torres"><addColumn tableName="pricing_rule"><column name="max_boost_percentage" type="NUMERIC(5,2)" defaultValueNumeric="0.00"><constraints nullable="false"/></column></addColumn><rollback><dropColumn tableName="pricing_rule" columnName="max_boost_percentage"/></rollback></changeSet></databaseChangeLog>
This is illustrative only (runnable: false) — a Liquibase XML changelog adding a new column to pricing_rule, the kind of small, reviewable, uniquely-identified unit of change that replaces a hand-run ALTER TABLE.
@Repositoryinterface PricingRuleRepository : JpaRepository<PricingRule, Long> {@Query("""SELECT r FROM PricingRule rWHERE r.vehicleCategory.id = :categoryIdAND r.effectiveFrom <= CURRENT_DATEAND (r.effectiveTo IS NULL OR r.effectiveTo > CURRENT_DATE)ORDER BY r.priority DESC""")fun findActiveRulesForCategory(categoryId: Long): List<PricingRule>}
This is illustrative only (runnable: false) — a Spring Data JPA repository over pricing_rule, showing the declarative, structured query the rules engine actually issues on every valuation.
🧠 Comprueba tu comprensión
0/1 · 0/1 answered1. Why does publishing a new rule set (tightening one rule while loosening another) specifically need a database transaction, rather than just two separate, sequential UPDATE statements?