Ir para o conteúdo
Backend

Versionamento de Schema com TypeORM: Migrations do Desenvolvimento ao Deploy

Marcos Soares
Atualizado em 
11 minutos de leitura
Ilustracao 3D de camadas de schema empilhadas em vidro translucido verde representando migrations de banco de dados
Ouça este artigo
0:00Versionamento de Schema com TypeORM: Migrations do Desenvolvimento ao Deploy--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O erro que destrói tabelas em produção

Uma migration com synchronize: true em produção apaga colunas. Não é bug do TypeORM: é comportamento documentado. O synchronize compara as entities do código com o schema atual e executa ALTER TABLE para alinhar os dois. Se você removeu uma propriedade da entity (ou renomeou), o synchronize interpreta como "drop column". Sem confirmação, sem rollback, sem aviso.

Esse é o ponto de partida deste post: migrations existem para que mudanças de schema sejam intencionais, rastreáveis e reversíveis. O TypeORM oferece tooling para isso, mas a configuração padrão empurra você na direção errada.

Configuração base do DataSource

O TypeORM 0.3+ substituiu ormconfig.json pelo DataSource. A configuração abaixo funciona para PostgreSQL e separa o comportamento por ambiente:

TypeScript
// src/data-source.ts
import { DataSource } from "typeorm";
import { join } from "path";
 
// Migrations ficam em pasta separada das entities
// para que o CLI encontre os arquivos compilados em produção
const isProduction = process.env.NODE_ENV === "production";
 
export const AppDataSource = new DataSource({
  type: "postgres",
  host: process.env.DB_HOST ?? "localhost",
  port: Number(process.env.DB_PORT ?? 5432),
  username: process.env.DB_USER ?? "app",
  password: process.env.DB_PASS ?? "secret",
  database: process.env.DB_NAME ?? "myapp",
 
  // synchronize NUNCA true em produção
  synchronize: false,
 
  // logging de queries só em dev para diagnóstico
  logging: isProduction ? ["error", "migration"] : true,
 
  entities: [join(__dirname, "entities", "*.{ts,js}")],
  migrations: [join(__dirname, "migrations", "*.{ts,js}")],
});

O synchronize: false é a única configuração segura para qualquer ambiente que persista dados reais. Em desenvolvimento, o fluxo correto é gerar migrations a partir das mudanças nas entities.

Gerando migrations a partir das entities

O CLI do TypeORM compara as entities registradas no DataSource com o schema atual do banco e gera o SQL necessário. Para que isso funcione, o banco de desenvolvimento precisa estar rodando e acessível.

Primeiro, configure o script no package.json:

JSON
{
  "scripts": {
    "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
    "migration:generate": "npm run typeorm -- migration:generate -d src/data-source.ts",
    "migration:run": "npm run typeorm -- migration:run -d src/data-source.ts",
    "migration:revert": "npm run typeorm -- migration:revert -d src/data-source.ts",
    "migration:create": "npm run typeorm -- migration:create"
  }
}

Com uma entity definida:

TypeScript
// src/entities/Product.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from "typeorm";
 
@Entity("products")
export class Product {
  @PrimaryGeneratedColumn("uuid")
  id: string;
 
  @Column({ type: "varchar", length: 255 })
  name: string;
 
  @Column({ type: "integer" })
  priceInCents: number;
 
  // Coluna nullable porque produtos existentes não terão SKU preenchido
  @Column({ type: "varchar", length: 50, nullable: true })
  sku: string | null;
 
  @CreateDateColumn({ type: "timestamptz" })
  createdAt: Date;
}

Gere a migration:

Bash
npm run migration:generate -- src/migrations/CreateProducts

O TypeORM cria um arquivo com timestamp e o SQL inferido. O resultado se parece com isto:

TypeScript
// src/migrations/1719432000000-CreateProducts.ts
import { MigrationInterface, QueryRunner } from "typeorm";
 
export class CreateProducts1719432000000 implements MigrationInterface {
  name = "CreateProducts1719432000000";
 
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      CREATE TABLE "products" (
        "id" uuid NOT NULL DEFAULT uuid_generate_v4(),
        "name" character varying(255) NOT NULL,
        "priceInCents" integer NOT NULL,
        "sku" character varying(50),
        "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
        CONSTRAINT "PK_products_id" PRIMARY KEY ("id")
      )
    `);
  }
 
  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP TABLE "products"`);
  }
}

Revise o SQL gerado antes de rodar. O TypeORM nem sempre gera o que você espera: nomes de constraints, ordem de colunas e tipos podem precisar de ajuste manual.

migration:generate vs. migration:create

Aspectomigration:generatemigration:create
Fonte do SQLDiff automático entre entities e schema atualArquivo vazio, você escreve o SQL
Quando usarMudanças estruturais refletidas nas entities (add column, create table)Data migrations, índices parciais, triggers, seeds
RiscoPode gerar DROP COLUMN se a entity mudou de nomeNenhum SQL automático, erro humano no SQL manual
Requer banco rodandoSimNão

Use migration:generate para mudanças de schema. Use migration:create para tudo que não se reflete em entities: popular dados, criar índices compostos com WHERE, adicionar extensões do PostgreSQL.

Migrations manuais para operações que o ORM não cobre

O TypeORM não gera índices parciais, extensões ou triggers. Para isso, crie a migration manualmente:

TypeScript
// src/migrations/1719433000000-AddSearchIndex.ts
import { MigrationInterface, QueryRunner } from "typeorm";
 
export class AddSearchIndex1719433000000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    // Índice parcial: só indexa produtos com SKU preenchido,
    // reduz tamanho do índice em ~40% quando metade dos produtos não tem SKU
    await queryRunner.query(`
      CREATE INDEX CONCURRENTLY "IDX_products_sku_partial"
      ON "products" ("sku")
      WHERE "sku" IS NOT NULL
    `);
  }
 
  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`DROP INDEX "IDX_products_sku_partial"`);
  }
}

Se você trabalha com busca full-text no PostgreSQL, criar colunas tsvector e índices GIN entra nessa mesma categoria de migration manual.

O que NÃO fazer

Anti-pattern 1: synchronize em produção

TypeScript
// ERRADO: synchronize ativo em produção
export const AppDataSource = new DataSource({
  type: "postgres",
  synchronize: process.env.NODE_ENV !== "test",
  // ...
});

O problema: qualquer mudança em uma entity (renomear propriedade, remover campo) gera DROP COLUMN automático no próximo boot da aplicação. Sem migration registrada, sem possibilidade de rollback, sem audit trail.

TypeScript
// CORRETO: synchronize desligado, migrations explícitas
export const AppDataSource = new DataSource({
  type: "postgres",
  synchronize: false,
  migrationsRun: false, // rodar via CLI, não no boot
  // ...
});

Anti-pattern 2: renomear coluna via entity sem migration manual

Se você renomeia priceInCents para unitPriceInCents na entity e roda migration:generate, o TypeORM gera:

TypeScript
// GERADO AUTOMATICAMENTE (PERIGOSO)
public async up(queryRunner: QueryRunner): Promise<void> {
  // TypeORM interpreta como DROP + ADD, não como RENAME
  await queryRunner.query(`ALTER TABLE "products" DROP COLUMN "priceInCents"`);
  await queryRunner.query(`ALTER TABLE "products" ADD "unitPriceInCents" integer NOT NULL`);
}

Todos os valores existentes na coluna priceInCents são perdidos. A correção é escrever a migration manualmente:

TypeScript
// CORRETO: rename preserva dados
public async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`
    ALTER TABLE "products"
    RENAME COLUMN "priceInCents" TO "unitPriceInCents"
  `);
}
 
public async down(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(`
    ALTER TABLE "products"
    RENAME COLUMN "unitPriceInCents" TO "priceInCents"
  `);
}

Anti-pattern 3: migration sem down funcional

TypeScript
// ERRADO: down vazio
public async down(): Promise<void> {
  // TODO: implement
}

Um down vazio impede rollback. Se a migration seguinte falhar, você fica preso num estado inconsistente. Escreva o down no mesmo momento que o up. Se a operação é irreversível (drop de dados), documente explicitamente:

TypeScript
public async down(): Promise<void> {
  throw new Error(
    "Migration irreversível: dados da coluna legacy_code foram migrados e a coluna original foi removida"
  );
}

Executando migrations no pipeline de CI/CD

O fluxo seguro separa build, migration e deploy em etapas distintas. Se você usa estratégias de deploy como blue-green ou canary, a migration precisa rodar antes do deploy da nova versão, e o schema novo precisa ser compatível com a versão antiga da aplicação (backward compatibility).

Um exemplo de step no GitHub Actions:

YAML
# .github/workflows/deploy.yml (trecho relevante)
jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm run build
      # Migration roda contra o banco de produção via variáveis de ambiente seguras
      - run: npx typeorm migration:run -d dist/data-source.js
        env:
          DB_HOST: ${{ secrets.DB_HOST }}
          DB_PORT: ${{ secrets.DB_PORT }}
          DB_USER: ${{ secrets.DB_USER }}
          DB_PASS: ${{ secrets.DB_PASS }}
          DB_NAME: ${{ secrets.DB_NAME }}
          NODE_ENV: production
 
  deploy:
    needs: migrate
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploy da aplicação após migration bem-sucedida"

Se o deploy roda dentro de containers Docker, a migration pode ser um entrypoint separado ou um job do orquestrador. O ponto crítico: a migration nunca deve rodar no migrationsRun: true do DataSource em produção. Isso acopla boot da aplicação com alteração de schema, e se duas instâncias subirem simultaneamente, ambas tentam rodar a mesma migration.

Tabela de controle: a tabela migrations

O TypeORM cria automaticamente uma tabela migrations (configurável via migrationsTableName) que registra quais migrations já rodaram. Cada linha contém o timestamp e o nome da classe. O migration:run executa apenas migrations não registradas nessa tabela, em ordem de timestamp.

Dois cuidados:

  1. Nunca delete linhas dessa tabela manualmente. Se precisar re-executar uma migration, use migration:revert para desfazer na ordem correta.
  2. Se dois desenvolvedores geram migrations com timestamps próximos e ambas alteram a mesma tabela, o merge pode criar conflito de schema. Resolva gerando uma nova migration após o merge que reconcilia o estado.

Migrations em monorepos

Se o projeto usa monorepo com Turborepo, isole o DataSource e as migrations no pacote que contém a camada de dados. O CLI do TypeORM precisa resolver o path do DataSource corretamente:

JSON
{
  "scripts": {
    "migration:generate": "ts-node -r tsconfig-paths/register ../../node_modules/typeorm/cli.js migration:generate -d src/data-source.ts"
  }
}

O path relativo para node_modules muda conforme o hoisting do package manager. Com pnpm (hoisting desabilitado por padrão), o TypeORM CLI precisa estar listado como dependência direta do pacote.

Testando migrations em CI

Um teste de sanidade que vale o investimento: subir um banco limpo, rodar todas as migrations, verificar que o schema final bate com as entities. Isso pega migrations corrompidas, conflitos de merge e SQL inválido antes de chegar em produção.

TypeScript
// src/__tests__/migrations.integration.test.ts
import { AppDataSource } from "../data-source";
 
// Requer banco PostgreSQL rodando (use testcontainers ou docker-compose no CI)
beforeAll(async () => {
  // Conecta com banco limpo dedicado para testes de migration
  await AppDataSource.initialize();
});
 
afterAll(async () => {
  await AppDataSource.destroy();
});
 
test("todas as migrations rodam sem erro e são reversíveis", async () => {
  // Roda todas as migrations pendentes
  const executedUp = await AppDataSource.runMigrations();
  expect(executedUp.length).toBeGreaterThan(0);
 
  // Reverte todas na ordem inversa
  for (const _ of executedUp) {
    await AppDataSource.undoLastMigration();
  }
 
  // Roda novamente para garantir idempotência do ciclo up/down/up
  const executedAgain = await AppDataSource.runMigrations();
  expect(executedAgain.length).toBe(executedUp.length);
});

Esse teste complementa os testes de API e garante que a camada de persistência não quebra silenciosamente.

Backward compatibility em deploys com zero downtime

Se a aplicação roda múltiplas instâncias (o cenário comum em produção), a migration precisa ser backward compatible: o schema novo deve funcionar com o código antigo que ainda está rodando durante o deploy.

A regra prática:

  • Adicionar coluna nullable ou com default: seguro. Código antigo ignora a coluna nova.
  • Remover coluna: perigoso. Código antigo ainda referencia a coluna. Faça em duas etapas: primeiro deploy remove a referência no código, segundo deploy (dias depois) remove a coluna via migration.
  • Renomear coluna: perigoso pelos mesmos motivos. Adicione a coluna nova, copie os dados, deploy o código que usa a coluna nova, depois remova a antiga.

Esse padrão de expand-and-contract é o mesmo usado em migrations seguras com Prisma. A ferramenta muda, o princípio não.

Quando o TypeORM não é a melhor escolha para migrations

Se o projeto usa TypeORM apenas para migrations (sem usar as entities/repositories no runtime), considere ferramentas dedicadas como node-pg-migrate ou dbmate. Elas são mais leves, não exigem decorators e funcionam com SQL puro.

Se o projeto já adota DDD com Prisma, misturar dois ORMs para ter migrations de ambos cria confusão. Escolha um e mantenha.

O TypeORM faz sentido para migrations quando você já usa o ORM no runtime e quer o benefício do migration:generate automático a partir das entities. Fora desse cenário, ferramentas SQL-first tendem a ser mais previsíveis.

FAQ

Posso usar synchronize: true em desenvolvimento local? Pode, mas não deveria. O synchronize não gera arquivos de migration, então você perde o histórico de mudanças. Se outro dev clonar o repositório, não terá como reproduzir o schema. Use migration:generate mesmo em dev.

Como lidar com migrations em branches diferentes que alteram a mesma tabela? Após o merge, rode migration:generate novamente. O TypeORM vai detectar o diff entre o schema atual (resultado das migrations de ambas branches) e as entities. Se houver conflito, a migration gerada resolve. Revise o SQL com atenção redobrada.

O migration:run é idempotente? Sim, no sentido de que não re-executa migrations já registradas na tabela migrations. Se a migration falhou no meio (DDL parcial sem transação), o estado fica inconsistente. PostgreSQL executa DDL dentro de transações, então migrations que usam apenas DDL são atômicas. MySQL não tem DDL transacional: uma migration que falha no meio deixa o schema num estado intermediário.

Devo commitar migrations geradas ou gerá-las no CI? Commite. Migrations são artefatos de versionamento, assim como o código. Gerar no CI significa que o schema depende do estado do banco no momento do build, o que é imprevisível.

Como rodar migrations em ambientes serverless com Neon? O mesmo CLI funciona. A diferença é que o connection string aponta para o endpoint do Neon. Cuidado com cold starts do banco: se o branch do Neon estiver suspenso, a primeira conexão pode demorar 2-5 segundos. Configure connectTimeoutMS no DataSource para evitar timeout no CI.

Posição editorial

Migrations são código de infraestrutura com impacto direto em dados de produção. Tratar migrations como "coisa que o ORM resolve sozinho" é aceitar que uma propriedade renomeada pode apagar uma coluna inteira sem aviso. O TypeORM oferece ferramentas boas para gerar e executar migrations, mas a responsabilidade de revisar o SQL gerado, escrever downs funcionais e garantir backward compatibility é do engenheiro. Automatize a execução, nunca a revisão.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Guias de integração relacionados

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.