Pular para o conteúdo principal

Drizzle ORM com PostgreSQL: Guia de Produção (Schema, RLS, Perf)

Use Drizzle ORM com Postgres em produção: design de schema, migrações, RLS, transações, performance. Padrões testados em uma app com 66 tabelas.

E
ECOSIRE Research and Development Team
|19 de março de 202613 min de leitura2.4k Palavras|

Drizzle ORM ocupa uma posição única no ecossistema de banco de dados TypeScript: é o ORM que na verdade é apenas um construtor de consultas disfarçado, com esquema como código que gera SQL que você pode ler e um sistema de migração que trata seu banco de dados como a fonte da verdade.

Drizzle ORM ocupa uma posição única no ecossistema de banco de dados TypeScript: é o ORM que na verdade é apenas um construtor de consultas disfarçado, com esquema como código que gera SQL que você pode ler e um sistema de migração que trata seu banco de dados como a fonte da verdade. Depois de anos de Prisma e TypeORM, Drizzle tem vontade de voltar para o SQL bruto - mas com inferência TypeScript completa e zero sobrecarga de tempo de execução.

Este guia cobre tudo, desde a configuração inicial até os padrões de produção, com base em uma base de código com mais de 65 arquivos de esquema Drizzle, consultas multilocatários e um padrão de conexão de proxy lento que funciona de maneira confiável em contextos Next.js e NestJS.

Principais conclusões

  • Use um proxy lento para conexão de banco de dados - nunca crie conexões antecipadas no tempo de carregamento do módulo
  • O PostgreSQL local deve ser executado em uma porta não padrão (5433) para evitar conflitos com instalações do sistema
  • Todas as tabelas precisam de organizationId para multilocação — aplicar no nível do esquema
  • As comparações de enum de Drizzle precisam de conversão explícita de TypeScript para restrição de tipo
  • Nunca use sql.raw() — sempre use o modelo literal sql com valores parametrizados
  • As relações são separadas das chaves estrangeiras — defina ambas para segurança completa do tipo
  • As transações envolvem operações de várias etapas; passe o objeto tx para serviços
  • O índice do esquema (schema/index.ts) deve exportar todas as tabelas para que as migrações funcionem

Instalação e configuração

Drizzle requer dois pacotes: o ORM principal e o driver de banco de dados.

pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg

Para a pilha completa, incluindo migrações e estúdio:

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit

O pacote postgres (diferente de pg) é o driver recomendado para Drizzle em projetos TypeScript modernos - ele suporta pooling de conexões, instruções preparadas e possui melhores tipos de TypeScript.


O padrão de proxy preguiçoso

A decisão arquitetônica mais crítica com Drizzle em um monorepo Next.js ou NestJS é nunca criar uma conexão de banco de dados ansiosa. Conexões ansiosas no tempo de carregamento do módulo causam problemas em:

  • Next.js: os módulos são importados durante o processo de construção, onde nenhum banco de dados está disponível
  • NestJS: as importações de pacotes acontecem antes do carregamento das variáveis de ambiente
  • Sem servidor: inicializações a frio são atrasadas pela sobrecarga de conexão

O padrão proxy preguiçoso resolve tudo isso:

// packages/db/src/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

let _db: ReturnType<typeof drizzle> | null = null;

function getDb() {
  if (!_db) {
    const connectionString = process.env.DATABASE_URL;
    if (!connectionString) {
      throw new Error('DATABASE_URL environment variable is not set');
    }

    const client = postgres(connectionString, {
      max: 10, // connection pool size
      idle_timeout: 20,
      connect_timeout: 10,
    });

    _db = drizzle(client, { schema });
  }
  return _db;
}

// Export a Proxy that initializes the connection on first use
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
  get(_, prop) {
    return getDb()[prop as keyof ReturnType<typeof drizzle>];
  },
});

export * from './schema';

Este padrão significa que a importação de @ecosire/db nunca abre uma conexão com o banco de dados. A conexão é criada somente quando a primeira consulta é executada.


Projeto de esquema

Esquemas Drizzle são arquivos TypeScript simples que exportam definições de tabela. A disciplina aqui é manter as tabelas relacionadas no mesmo arquivo e exportar tudo através de um índice central.

// packages/db/src/schema/contacts.ts
import {
  pgTable,
  uuid,
  text,
  varchar,
  timestamp,
  pgEnum,
  boolean,
  index,
} from 'drizzle-orm/pg-core';

export const contactTypeEnum = pgEnum('contact_type', ['individual', 'company', 'partner']);

export const contacts = pgTable(
  'contacts',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    organizationId: uuid('organization_id').notNull(), // Multi-tenancy
    name: varchar('name', { length: 255 }).notNull(),
    email: varchar('email', { length: 255 }),
    phone: varchar('phone', { length: 50 }),
    type: contactTypeEnum('type').default('individual').notNull(),
    isActive: boolean('is_active').default(true).notNull(),
    notes: text('notes'),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => ({
    organizationIdx: index('contacts_organization_idx').on(table.organizationId),
    emailIdx: index('contacts_email_idx').on(table.email),
  })
);

export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;

Os tipos $inferSelect e $inferInsert fornecem inferência TypeScript completa - Drizzle sabe quais campos são obrigatórios ou opcionais no momento da inserção com base nas restrições do seu esquema.

// packages/db/src/schema/index.ts
export * from './contacts';
export * from './orders';
export * from './licenses';
export * from './products';
// ... all tables

O arquivo de índice deve exportar todas as tabelas para drizzle-kit para pegá-las durante a geração da migração.


Relações

O Drizzle separa as restrições de chave estrangeira (aplicadas pelo PostgreSQL) das definições de relação (usadas pela API de consulta relacional do Drizzle). Você precisa de ambos:

// packages/db/src/schema/relations.ts
import { relations } from 'drizzle-orm';
import { contacts } from './contacts';
import { orders } from './orders';
import { orderItems } from './order-items';
import { products } from './products';

export const contactsRelations = relations(contacts, ({ many }) => ({
  orders: many(orders),
}));

export const ordersRelations = relations(orders, ({ one, many }) => ({
  contact: one(contacts, {
    fields: [orders.contactId],
    references: [contacts.id],
  }),
  items: many(orderItems),
}));

export const orderItemsRelations = relations(orderItems, ({ one }) => ({
  order: one(orders, {
    fields: [orderItems.orderId],
    references: [orders.id],
  }),
  product: one(products, {
    fields: [orderItems.productId],
    references: [products.id],
  }),
}));

Com as relações definidas, você pode usar a API de consulta relacional do Drizzle:

// Fetch order with contact and items in one query
const order = await db.query.orders.findFirst({
  where: eq(orders.id, orderId),
  with: {
    contact: true,
    items: {
      with: {
        product: true,
      },
    },
  },
});

Drizzle gera uma única consulta JOIN – sem problemas N+1.


Consultas de tipo seguro

A API de consulta do Drizzle é totalmente segura. O tipo de seleção é restringido com base nas colunas que você inclui:

import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and, like, sql, count } from 'drizzle-orm';

// Full select — returns Contact[]
const allContacts = await db
  .select()
  .from(contacts)
  .where(eq(contacts.organizationId, orgId))
  .limit(50);

// Partial select — returns only specified columns
const contactNames = await db
  .select({
    id: contacts.id,
    name: contacts.name,
    email: contacts.email,
  })
  .from(contacts)
  .where(
    and(
      eq(contacts.organizationId, orgId),
      eq(contacts.isActive, true)
    )
  );

// Count query
const [{ total }] = await db
  .select({ total: count() })
  .from(contacts)
  .where(eq(contacts.organizationId, orgId));

// Search with ILIKE (case-insensitive)
const searchResults = await db
  .select()
  .from(contacts)
  .where(
    and(
      eq(contacts.organizationId, orgId),
      like(contacts.name, `%${searchTerm}%`)
    )
  )
  .limit(20);

Para cláusulas where dinâmicas (filtros da UI), crie condições condicionalmente:

import { SQL } from 'drizzle-orm';

async function searchContacts(orgId: string, filters: {
  search?: string;
  type?: 'individual' | 'company';
  isActive?: boolean;
}) {
  const conditions: SQL[] = [eq(contacts.organizationId, orgId)];

  if (filters.search) {
    conditions.push(like(contacts.name, `%${filters.search}%`));
  }

  if (filters.type) {
    // Enum comparisons need explicit casting
    conditions.push(eq(contacts.type, filters.type as 'individual' | 'company' | 'partner'));
  }

  if (filters.isActive !== undefined) {
    conditions.push(eq(contacts.isActive, filters.isActive));
  }

  return db
    .select()
    .from(contacts)
    .where(and(...conditions))
    .limit(100);
}

Observe a conversão explícita nos valores enum: filters.type as 'individual' | 'company' | 'partner'. O tipo enum do Drizzle não aceita automaticamente subtipos de string mais estreitos sem a conversão.


Inserir, atualizar e excluir

import { db } from '@ecosire/db';
import { contacts, NewContact } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';

// Insert with RETURNING
async function createContact(data: NewContact) {
  const [created] = await db
    .insert(contacts)
    .values(data)
    .returning();

  return created; // Fully typed as Contact
}

// Upsert (insert or update on conflict)
async function upsertContact(data: NewContact) {
  const [result] = await db
    .insert(contacts)
    .values(data)
    .onConflictDoUpdate({
      target: [contacts.organizationId, contacts.email],
      set: {
        name: data.name,
        updatedAt: new Date(),
      },
    })
    .returning();

  return result;
}

// Update with RETURNING
async function updateContact(
  id: string,
  orgId: string,
  updates: Partial<NewContact>
) {
  const [updated] = await db
    .update(contacts)
    .set({ ...updates, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, id),
        eq(contacts.organizationId, orgId) // Always filter by org
      )
    )
    .returning();

  return updated;
}

// Soft delete (recommended over hard delete)
async function deleteContact(id: string, orgId: string) {
  return db
    .update(contacts)
    .set({ isActive: false, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, id),
        eq(contacts.organizationId, orgId)
      )
    );
}

Sempre use .returning() em instruções INSERT e UPDATE — isso evita uma segunda viagem de ida e volta SELECT para obter o registro criado/atualizado.


Transações

Operações de várias etapas que devem ser bem-sucedidas ou falhar juntas precisam de transações:

async function createOrderWithItems(
  orderData: NewOrder,
  items: NewOrderItem[]
) {
  return db.transaction(async (tx) => {
    // Create the order
    const [order] = await tx
      .insert(orders)
      .values(orderData)
      .returning();

    // Create order items
    const createdItems = await tx
      .insert(orderItems)
      .values(items.map((item) => ({ ...item, orderId: order.id })))
      .returning();

    // Update inventory (must succeed or entire transaction rolls back)
    for (const item of items) {
      await tx
        .update(products)
        .set({
          stock: sql`${products.stock} - ${item.quantity}`,
        })
        .where(eq(products.id, item.productId));
    }

    return { order, items: createdItems };
  });
}

Ao passar o contexto da transação para métodos de serviço:

// Service accepts optional transaction for composability
async function activateLicense(
  licenseId: string,
  tx?: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
) {
  const queryRunner = tx || db;

  return queryRunner
    .update(licenses)
    .set({ status: 'active', activatedAt: new Date() })
    .where(eq(licenses.id, licenseId))
    .returning();
}

Migrações com kit drizzle

A CLI drizzle-kit gera migrações a partir de alterações de esquema:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/schema/index.ts',
  out: './src/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

Fluxo de trabalho:

# Generate migration SQL from schema changes
npx drizzle-kit generate

# Apply migrations to database
npx drizzle-kit migrate

# Push schema directly (dev only — skips migration files)
npx drizzle-kit push

# Open Drizzle Studio (visual database browser)
npx drizzle-kit studio

Para produção, use sempre migrate (não push). O comando push é para iteração de desenvolvimento local — ele não cria arquivos de migração, portanto as alterações não são rastreadas.


Padrão de paginação

A paginação eficiente requer uma consulta de dados e uma consulta de contagem:

interface PaginationParams {
  page: number;
  pageSize: number;
}

async function getContactsPaginated(orgId: string, { page, pageSize }: PaginationParams) {
  const offset = (page - 1) * pageSize;

  const [data, [{ total }]] = await Promise.all([
    db
      .select()
      .from(contacts)
      .where(eq(contacts.organizationId, orgId))
      .limit(pageSize)
      .offset(offset)
      .orderBy(contacts.createdAt),
    db
      .select({ total: count() })
      .from(contacts)
      .where(eq(contacts.organizationId, orgId)),
  ]);

  return {
    data,
    pagination: {
      page,
      pageSize,
      total,
      totalPages: Math.ceil(total / pageSize),
    },
  };
}

A execução de ambas as consultas em paralelo com Promise.all reduz pela metade o tempo de resposta em comparação com a execução sequencial.


Armadilhas e soluções comuns

Armadilha 1: usar sql.raw() para consultas dinâmicas

// Dangerous — SQL injection vulnerability
const results = await db.execute(
  sql.raw(`SELECT * FROM contacts WHERE name = '${userInput}'`)
);

// Safe — parameterized
const results = await db.execute(
  sql`SELECT * FROM contacts WHERE name = ${userInput}`
);

Armada 2: índices ausentes em colunas consultadas com frequência

Sempre adicione índices às colunas pelas quais você filtra. Os índices perdidos mais comuns:

(table) => ({
  organizationIdx: index('contacts_org_idx').on(table.organizationId),
  emailIdx: index('contacts_email_idx').on(table.email),
  compositeIdx: index('contacts_org_active_idx').on(
    table.organizationId,
    table.isActive
  ),
})

Armadilha 3: consultas N+1 do carregamento manual de relações

// N+1 problem — one query per order
const orders = await db.select().from(ordersTable);
for (const order of orders) {
  order.contact = await db
    .select()
    .from(contacts)
    .where(eq(contacts.id, order.contactId));
}

// Solution — use Drizzle's relational query API
const orders = await db.query.orders.findMany({
  with: { contact: true },
});

Armadilha 4: Esquecer de exportar novas tabelas de schema/index.ts

Se você criar um novo arquivo de esquema, mas esquecer de exportar novamente de schema/index.ts, drizzle-kit não verá a nova tabela e as migrações não serão geradas.


Perguntas frequentes

Como o Drizzle se compara ao Prisma para projetos TypeScript?

Drizzle está mais próximo do SQL bruto com tipos TypeScript – você escreve consultas que mapeiam 1:1 para SQL. O Prisma abstrai mais, com uma linguagem de consulta personalizada e um arquivo de esquema separado. Drizzle tem melhor inferência TypeScript em consultas complexas, menor sobrecarga de tempo de execução e não requer uma etapa de geração de código. O Prisma tem um ecossistema maior e mais recursos integrados, como exclusões suaves e registros de auditoria. Para equipes grandes que estão confortáveis ​​com SQL, o Drizzle geralmente é o preferido.

Devo usar drizzle-kit push ou migrar em CI/CD?

Sempre use drizzle-kit migrate em CI/CD. O comando push é para desenvolvimento local rápido — ele aplica alterações de esquema diretamente, sem criar arquivos de migração, portanto, não há trilha de auditoria e a reversão é difícil. No pipeline de implantação, execute drizzle-kit migrate antes de iniciar o aplicativo para garantir que o esquema esteja sincronizado.

Como lidar com a propagação do banco de dados com o Drizzle?

Crie um arquivo inicial separado e importe seu esquema diretamente. Use onConflictDoNothing() para sementes idempotentes que não falharão na nova execução. Armazene dados iniciais em constantes para atualizações fáceis e execute sementes após as migrações em seu script de configuração de desenvolvimento.

Como faço pesquisa de texto completo com Drizzle e PostgreSQL?

Use o modelo literal sql com as funções to_tsvector e to_tsquery do PostgreSQL. O Drizzle não abstrai a pesquisa de texto completo, mas você pode escrevê-la diretamente. Adicione um índice GIN na coluna tsvector para desempenho e use websearch_to_tsquery para termos de pesquisa fornecidos pelo usuário para analisar com segurança consultas em linguagem natural.

Como lidar com alterações de esquema sem tempo de inatividade?

Use migrações aditivas – adicione colunas como anuláveis ​​primeiro, preencha os dados e, em seguida, adicione a restrição NOT NULL. Nunca descarte colunas na mesma migração que as remove do código. A sequência é: 1) Implantar código que ignore a coluna antiga, 2) Implantar migração para eliminá-la. Isso garante que a reversão seja sempre possível sem perda de dados.


Próximas etapas

Uma camada de banco de dados bem projetada é a base sobre a qual todo aplicativo de produção é construído. A equipe de engenharia da ECOSIRE trabalha diariamente com Drizzle ORM e PostgreSQL, gerenciando mais de 65 arquivos de esquema em um sistema complexo multilocatário.

Se você precisa de consultoria de arquitetura de banco de dados, integração Odoo ERP ou um sistema back-end completo construído com ferramentas TypeScript modernas, explore nossos serviços para saber como podemos ajudar.

E

Escrito por

ECOSIRE Research and Development Team

Redação técnica

A equipe de redação técnica da ECOSIRE escreve sobre Odoo ERP, e-commerce com Shopify, agentes de IA, análise de dados com Power BI, automação com GoHighLevel e boas práticas de software empresarial. Nossos guias ajudam as empresas a tomar decisões de tecnologia bem fundamentadas.

ECOSIRE

Expanda o seu negócio com ECOSIRE

Soluções empresariais em ERP, comércio eletrônico, IA, análise e automação.