Skip to content

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

CriterioDbUpEF Migrations
Control sobre SQLTotal — escribimos el SQLGenerado automaticamente
RLS policiesSoporte nativoRequiere SQL manual en Up()
Funciones/TriggersSoporte nativoWorkarounds con migrationBuilder.Sql()
RollbacksComentario en el scriptMetodo Down() que nadie mantiene
AuditoriaTabla schema_versionsTabla __EFMigrationsHistory
IdempotenciaScripts inmutablesModelo diff que puede fallar
Multi-databaseSimple — apuntar connection stringComplejo 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.csproj

Program.cs

csharp
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.sql

Seeds (ejecutar siempre — idempotentes)

Formato: S{NNN}__{descripcion_en_snake_case}.sql

  • S = Seed
  • Deben ser idempotentes (usar ON CONFLICT DO NOTHING o INSERT ... WHERE NOT EXISTS)

Ejemplos:

S001__insert_default_product_types.sql
S002__insert_system_parameters.sql
S003__insert_default_roles.sql

Reglas 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.

sql
-- 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.

sql
-- 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.

sql
-- 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.

sql
row_version INTEGER NOT NULL DEFAULT 1

La 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

sql
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
  • created_at se establece automaticamente al insertar
  • updated_at se actualiza via trigger o desde la aplicacion

Trigger estandar para updated_at

sql
-- 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

sql
-- 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

sql
-- 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

bash
# 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-migrations

Variables de Entorno (local)

env
# .env.local (en .gitignore)
ConnectionStrings__LendingDb=Host=localhost;Port=5432;Database=imagy_lending;Username=imagy;Password=local_dev_password

CI/CD Pipeline

Las migraciones se ejecutan automaticamente en el pipeline antes del deployment del servicio:

yaml
# .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 servicio

Orden 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:

sql
-- 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:

sql
-- 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

  1. Nunca renombrar o eliminar una columna en un solo paso
  2. Nunca cambiar el tipo de una columna si reduce precision
  3. Agregar columnas con DEFAULT para evitar locks largos en tablas grandes
  4. Usar CREATE INDEX CONCURRENTLY para indices en tablas con muchos registros
  5. 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:

  1. Syntax check — el SQL es valido
  2. Naming convention — el archivo sigue el formato V{NNN}__ o S{NNN}__
  3. RLS check — tablas con tenant_id tienen RLS habilitado
  4. Rollback present — el script contiene seccion == ROLLBACK ==
  5. No destructive without planDROP COLUMN o DROP TABLE requieren aprobacion manual
bash
# Script de validacion (ejecutado en CI)
./scripts/validate-migrations.sh src/Imagy.Lending.Migrations/Scripts/

Reimagine Tech LLC — Documentacion Interna