Migraciones de Base de Datos — Plataforma Imagy
Herramienta: DbUp
Usamos DbUp como herramienta de migraciones en lugar de EF Core Migrations. DbUp ejecuta scripts SQL puros en orden secuencial, lo que nos da control total sobre el DDL y permite incluir funciones, triggers, politicas RLS y configuraciones especificas de PostgreSQL que EF Migrations no soporta nativamente.
Por que DbUp y no EF Migrations
| Criterio | DbUp | EF Migrations |
|---|---|---|
| Control sobre SQL | Total — escribimos el SQL | Generado automaticamente |
| RLS policies | Soporte nativo | Requiere SQL manual en Up() |
| Funciones/Triggers | Soporte nativo | Workarounds con migrationBuilder.Sql() |
| Rollbacks | Comentario en el script | Metodo Down() que nadie mantiene |
| Auditoria | Tabla schema_versions | Tabla __EFMigrationsHistory |
| Idempotencia | Scripts inmutables | Modelo diff que puede fallar |
| Multi-database | Simple — apuntar connection string | Complejo con multiples DbContexts |
Estructura del Proyecto
Cada dominio tiene su propio proyecto de migraciones:
Imagy.{Domain}.Migrations/
├── Scripts/
│ ├── V001__create_credit_products_table.sql
│ ├── V002__add_interest_rate_tiers.sql
│ ├── V003__create_loan_applications_table.sql
│ └── V004__add_disbursement_tracking.sql
├── Seed/
│ ├── S001__insert_default_product_types.sql
│ └── S002__insert_system_parameters.sql
├── Program.cs
└── Imagy.Lending.Migrations.csprojProgram.cs
using DbUp;
using Microsoft.Extensions.Configuration;
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
var connectionString = configuration.GetConnectionString("LendingDb")
?? throw new InvalidOperationException("Connection string 'LendingDb' not found");
// Ejecutar migraciones
var upgrader = DeployChanges.To
.PostgresqlDatabase(connectionString)
.WithScriptsFromFileSystem("Scripts/", new SqlScriptOptions
{
ScriptType = ScriptType.RunOnce,
RunGroupOrder = 1
})
.WithTransaction()
.LogToConsole()
.Build();
var result = upgrader.PerformUpgrade();
if (!result.Successful)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(result.Error);
return -1;
}
// Ejecutar seeds (idempotentes — se ejecutan siempre)
var seeder = DeployChanges.To
.PostgresqlDatabase(connectionString)
.WithScriptsFromFileSystem("Seed/", new SqlScriptOptions
{
ScriptType = ScriptType.RunAlways,
RunGroupOrder = 2
})
.WithTransaction()
.LogToConsole()
.Build();
var seedResult = seeder.PerformUpgrade();
if (!seedResult.Successful)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(seedResult.Error);
return -1;
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Migraciones ejecutadas exitosamente.");
return 0;Nomenclatura de Scripts
Migraciones (ejecutar una vez)
Formato: V{NNN}__{descripcion_en_snake_case}.sql
V= Version (migration){NNN}= Numero secuencial de 3 digitos__= Doble underscore (separador de DbUp)- Descripcion en snake_case, en ingles
Ejemplos:
V001__create_credit_products_table.sql
V002__add_interest_rate_tiers.sql
V003__create_loan_applications_table.sql
V004__add_status_index_to_applications.sql
V005__alter_products_add_max_disbursements.sqlSeeds (ejecutar siempre — idempotentes)
Formato: S{NNN}__{descripcion_en_snake_case}.sql
S= Seed- Deben ser idempotentes (usar
ON CONFLICT DO NOTHINGoINSERT ... WHERE NOT EXISTS)
Ejemplos:
S001__insert_default_product_types.sql
S002__insert_system_parameters.sql
S003__insert_default_roles.sqlReglas de Migraciones
1. Los scripts son inmutables una vez aplicados
Una vez que un script se ejecuta en cualquier ambiente (incluyendo desarrollo local de otro desarrollador), nunca se modifica. Si hay un error, se crea un nuevo script correctivo.
-- INCORRECTO: modificar V001 despues de que ya se aplico
-- CORRECTO: crear V002 con la correccion
-- V002__fix_credit_products_amount_precision.sql
ALTER TABLE credit_products
ALTER COLUMN min_amount TYPE numeric(18,4),
ALTER COLUMN max_amount TYPE numeric(18,4);2. Siempre incluir rollback como comentario
Cada script debe incluir al final un bloque comentado con las instrucciones de rollback. Esto facilita la reversion manual en caso de emergencia.
-- V003__create_loan_applications_table.sql
CREATE TABLE loan_applications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
product_id UUID NOT NULL REFERENCES credit_products(id),
applicant_id UUID NOT NULL,
requested_amount NUMERIC(18,4) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
row_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
CREATE INDEX idx_loan_applications_tenant_status
ON loan_applications(tenant_id, status);
-- Habilitar RLS
ALTER TABLE loan_applications ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON loan_applications
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- == ROLLBACK ==
-- DROP POLICY IF EXISTS tenant_isolation ON loan_applications;
-- DROP TABLE IF EXISTS loan_applications;3. RLS obligatorio en tablas nuevas con tenant_id
Toda tabla que contenga tenant_id debe tener Row Level Security habilitado. Esto es una capa de defensa adicional que previene fugas de datos entre tenants incluso si hay un bug en la aplicacion.
-- Patron estandar para RLS
ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON {table_name}
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Para operaciones de INSERT, asegurar que el tenant_id coincide
CREATE POLICY tenant_insert ON {table_name}
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);4. Siempre incluir row_version para ETags
Las tablas que soportan concurrencia optimista (la mayoria de tablas de negocio) deben incluir row_version. Este campo se usa para generar ETags en las respuestas HTTP.
row_version INTEGER NOT NULL DEFAULT 1La aplicacion incrementa row_version en cada UPDATE y valida que el valor no haya cambiado antes de escribir.
5. Siempre incluir created_at y updated_at
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZcreated_atse establece automaticamente al insertarupdated_atse actualiza via trigger o desde la aplicacion
Trigger estandar para updated_at
-- Crear la funcion una sola vez (en V001 del dominio)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Aplicar a cada tabla
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON credit_products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();Ejemplo Completo de Migracion
-- V001__create_credit_products_table.sql
-- Descripcion: Tabla principal de productos de credito
-- Autor: equipo-lending
-- Fecha: 2025-01-15
-- Tabla principal
CREATE TABLE credit_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
min_amount NUMERIC(18,4) NOT NULL,
max_amount NUMERIC(18,4) NOT NULL,
interest_rate NUMERIC(8,6) NOT NULL,
min_term_days INTEGER NOT NULL,
max_term_days INTEGER NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
row_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT chk_amount_range CHECK (max_amount > min_amount),
CONSTRAINT chk_min_amount_positive CHECK (min_amount > 0),
CONSTRAINT chk_interest_rate_range CHECK (interest_rate >= 0 AND interest_rate <= 1),
CONSTRAINT chk_term_range CHECK (max_term_days > min_term_days),
CONSTRAINT chk_min_term_positive CHECK (min_term_days > 0),
CONSTRAINT uq_credit_products_tenant_code UNIQUE (tenant_id, code)
);
-- Indices
CREATE INDEX idx_credit_products_tenant_active
ON credit_products(tenant_id, is_active);
CREATE INDEX idx_credit_products_code
ON credit_products(tenant_id, code);
-- RLS
ALTER TABLE credit_products ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON credit_products
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_insert ON credit_products
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Trigger updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON credit_products
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- == ROLLBACK ==
-- DROP TRIGGER IF EXISTS set_updated_at ON credit_products;
-- DROP FUNCTION IF EXISTS update_updated_at_column();
-- DROP POLICY IF EXISTS tenant_insert ON credit_products;
-- DROP POLICY IF EXISTS tenant_isolation ON credit_products;
-- DROP TABLE IF EXISTS credit_products;Ejemplo de Seed Idempotente
-- S001__insert_default_product_types.sql
-- Descripcion: Tipos de producto por defecto para nuevos tenants
-- Nota: Este script se ejecuta en cada deployment (idempotente)
INSERT INTO product_types (id, code, name, description)
VALUES
('a1b2c3d4-0000-0000-0000-000000000001', 'personal-loan', 'Prestamo Personal', 'Credito de libre disponibilidad'),
('a1b2c3d4-0000-0000-0000-000000000002', 'micro-credit', 'Microcredito', 'Credito para microempresarios'),
('a1b2c3d4-0000-0000-0000-000000000003', 'mortgage', 'Hipotecario', 'Credito con garantia hipotecaria'),
('a1b2c3d4-0000-0000-0000-000000000004', 'vehicle', 'Vehicular', 'Credito para compra de vehiculo')
ON CONFLICT (id) DO NOTHING;Ejecucion de Migraciones
Desarrollo Local
# Opcion 1: Ejecutar el proyecto directamente
cd src/Imagy.Lending.Migrations
dotnet run
# Opcion 2: Usar el script de conveniencia
./scripts/migrate.sh lending
# Opcion 3: Docker Compose (levanta PostgreSQL + ejecuta migraciones)
docker compose up -d postgres
docker compose run --rm lending-migrationsVariables de Entorno (local)
# .env.local (en .gitignore)
ConnectionStrings__LendingDb=Host=localhost;Port=5432;Database=imagy_lending;Username=imagy;Password=local_dev_passwordCI/CD Pipeline
Las migraciones se ejecutan automaticamente en el pipeline antes del deployment del servicio:
# .github/workflows/deploy.yml (fragmento)
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Get database credentials
run: |
DB_SECRET=$(aws secretsmanager get-secret-value \
--secret-id imagy/${{ env.ENVIRONMENT }}/lending/database \
--query SecretString --output text)
echo "CONNECTION_STRING=$(echo $DB_SECRET | jq -r .connection_string)" >> $GITHUB_ENV
- name: Run migrations
run: |
dotnet run --project src/Imagy.Lending.Migrations/ \
-- --connection "${{ env.CONNECTION_STRING }}"
deploy:
needs: migrate
# ... deployment del servicioOrden de Ejecucion en Deploy
Cambios Breaking — Migraciones Multi-Paso
Cuando un cambio de esquema es incompatible con la version actual del servicio (por ejemplo, renombrar una columna), se ejecuta en multiples pasos distribuidos en diferentes releases.
Ejemplo: Renombrar columna interest_rate a annual_rate
Release 1 — Agregar nueva columna + sync trigger:
-- V010__add_annual_rate_column.sql
ALTER TABLE credit_products ADD COLUMN annual_rate NUMERIC(8,6);
-- Copiar datos existentes
UPDATE credit_products SET annual_rate = interest_rate;
-- Trigger para mantener sincronizadas ambas columnas durante la transicion
CREATE OR REPLACE FUNCTION sync_rate_columns()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.interest_rate IS DISTINCT FROM OLD.interest_rate THEN
NEW.annual_rate = NEW.interest_rate;
ELSIF NEW.annual_rate IS DISTINCT FROM OLD.annual_rate THEN
NEW.interest_rate = NEW.annual_rate;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_rates
BEFORE UPDATE ON credit_products
FOR EACH ROW
EXECUTE FUNCTION sync_rate_columns();
-- == ROLLBACK ==
-- DROP TRIGGER IF EXISTS trg_sync_rates ON credit_products;
-- DROP FUNCTION IF EXISTS sync_rate_columns();
-- ALTER TABLE credit_products DROP COLUMN IF EXISTS annual_rate;Release 2 — El servicio lee/escribe annual_rate (codigo actualizado).
Release 3 — Eliminar columna vieja:
-- V015__drop_interest_rate_column.sql
DROP TRIGGER IF EXISTS trg_sync_rates ON credit_products;
DROP FUNCTION IF EXISTS sync_rate_columns();
ALTER TABLE credit_products DROP COLUMN interest_rate;
ALTER TABLE credit_products ALTER COLUMN annual_rate SET NOT NULL;
-- == ROLLBACK ==
-- ALTER TABLE credit_products ADD COLUMN interest_rate NUMERIC(8,6);
-- UPDATE credit_products SET interest_rate = annual_rate;Diagrama de Migracion Multi-Paso
Reglas para Cambios Breaking
- Nunca renombrar o eliminar una columna en un solo paso
- Nunca cambiar el tipo de una columna si reduce precision
- Agregar columnas con
DEFAULTpara evitar locks largos en tablas grandes - Usar
CREATE INDEX CONCURRENTLYpara indices en tablas con muchos registros - Documentar el plan multi-paso en el PR con links a los PRs subsecuentes
Validacion Pre-Deploy
Antes de ejecutar migraciones en produccion, el pipeline valida:
- Syntax check — el SQL es valido
- Naming convention — el archivo sigue el formato
V{NNN}__oS{NNN}__ - RLS check — tablas con
tenant_idtienen RLS habilitado - Rollback present — el script contiene seccion
== ROLLBACK == - No destructive without plan —
DROP COLUMNoDROP TABLErequieren aprobacion manual
# Script de validacion (ejecutado en CI)
./scripts/validate-migrations.sh src/Imagy.Lending.Migrations/Scripts/