Ir para o conteúdo
Framework com Framework1648 palavras

Como usar Drizzle com NestJS: Guia Completo

Introdução

Se você trabalha com Node.js há algum tempo, provavelmente já passou pela experiência de escolher um ORM e se arrepender meses depois. O Prisma popularizou a ideia de type safety no acesso ao banco, mas trouxe consigo um runtime pesado e abstrações que, em cenários de alta performance, se tornam gargalos reais. O Drizzle ORM surgiu como uma alternativa que abraça o SQL ao invés de escondê-lo, oferecendo tipagem completa sem a necessidade de um engine intermediário.

O NestJS, por sua vez, continua sendo o framework mais robusto do ecossistema Node para construir aplicações backend escaláveis. Sua arquitetura modular, inspirada no Angular, favorece a injeção de dependências e a separação de responsabilidades de forma natural.

A combinação dos dois é poderosa: você ganha a estrutura arquitetural do NestJS com a leveza e o controle fino do Drizzle sobre suas queries. Neste guia, vou mostrar como integrar essas duas ferramentas do zero, com configurações reais e padrões que uso em produção.

Se você quer entender como o Drizzle se compara ao Prisma em termos de performance bruta, recomendo a leitura do post Prisma ORM vs Drizzle ORM: Benchmark Real em 2025.

Por que usar Drizzle com NestJS

Type safety sem custo de runtime

O Drizzle gera tipos TypeScript diretamente a partir do seu schema. Diferente do Prisma, que precisa de um client gerado e um engine binário para funcionar, o Drizzle opera como uma biblioteca pura de TypeScript. Isso significa bundles menores, cold starts mais rápidos (relevante para serverless) e menos camadas entre o seu código e o banco de dados.

Controle total sobre o SQL

O Drizzle não tenta esconder o SQL. Pelo contrário: sua API é desenhada para que cada método corresponda a uma cláusula SQL real. Quem tem familiaridade com SQL relacional se sente em casa imediatamente. Isso elimina aquela sensação de "lutar contra o ORM" quando a query fica mais complexa.

Encaixe natural com a arquitetura modular do NestJS

O NestJS favorece a criação de módulos independentes com providers injetáveis. O Drizzle se adapta perfeitamente a esse modelo: você cria um módulo de banco de dados, expõe a instância do Drizzle como provider e injeta onde precisar. Sem mágica, sem decorators proprietários, sem acoplamento desnecessário.

Casos de uso onde essa combinação brilha

Existem cenários onde essa dupla se destaca com clareza:

  • APIs de alta performance: quando cada milissegundo de latência importa e você precisa de queries otimizadas manualmente.
  • Aplicações serverless: o Drizzle não carrega binários nativos, o que reduz drasticamente o tamanho do deploy e o tempo de cold start.
  • Projetos com schemas complexos: joins elaborados, subqueries e CTEs são cidadãos de primeira classe no Drizzle.
  • Migrações controladas: o Drizzle Kit gera migrações SQL puras que você pode versionar, revisar e aplicar com total transparência.

Setup completo do Drizzle ORM com NestJS

Instalação das dependências

Vamos trabalhar com PostgreSQL neste guia, mas o Drizzle também suporta MySQL, SQLite e outros drivers.

TYPESCRIPT
// Terminal
// npm install drizzle-orm postgres
// npm install -D drizzle-kit @types/node

O pacote postgres é o driver PostgreSQL baseado em conexões nativas (postgres.js). Você pode usar pg se preferir, mas o postgres oferece performance superior na maioria dos benchmarks.

Definindo o schema do banco de dados

Crie uma pasta src/database para organizar tudo que se refere ao acesso a dados. O primeiro arquivo é o schema:

TYPESCRIPT
// src/database/schema.ts
import {
  pgTable,
  uuid,
  varchar,
  text,
  timestamp,
  integer,
  boolean,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
 
export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 255 }).notNull(),
  role: varchar('role', { length: 50 }).notNull().default('user'),
  isActive: boolean('is_active').notNull().default(true),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
 
export const posts = pgTable('posts', {
  id: uuid('id').defaultRandom().primaryKey(),
  title: varchar('title', { length: 500 }).notNull(),
  content: text('content').notNull(),
  authorId: uuid('author_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  viewCount: integer('view_count').notNull().default(0),
  published: boolean('published').notNull().default(false),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
 
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));
 
export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
}));

Repare que o schema é declarativo e fortemente tipado. Cada coluna carrega seu tipo TypeScript automaticamente, e as relações são definidas de forma explícita.

Configurando o módulo de banco de dados no NestJS

Aqui está o ponto central da integração. Vamos criar um módulo global que expõe a instância do Drizzle para toda a aplicação:

TYPESCRIPT
// src/database/database.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
 
export const DRIZZLE = Symbol('DRIZZLE');
 
export type DrizzleDatabase = PostgresJsDatabase<typeof schema>;
 
@Global()
@Module({
  providers: [
    {
      provide: DRIZZLE,
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const connectionString = configService.getOrThrow<string>('DATABASE_URL');
 
        const client = postgres(connectionString, {
          max: 20,
          idle_timeout: 20,
          connect_timeout: 10,
        });
 
        return drizzle(client, { schema });
      },
    },
  ],
  exports: [DRIZZLE],
})
export class DatabaseModule {}

Alguns pontos importantes sobre essa configuração:

  • O decorator @Global() torna o módulo disponível em toda a aplicação sem necessidade de importá-lo repetidamente.
  • O Symbol('DRIZZLE') serve como token de injeção, evitando conflitos de nomes.
  • O tipo DrizzleDatabase é exportado para que outros arquivos possam tipar corretamente a instância injetada.
  • Os parâmetros de pool (max, idle_timeout, connect_timeout) são essenciais em produção para evitar esgotamento de conexões.

Configuração do Drizzle Kit para migrações

TYPESCRIPT
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
 
export default defineConfig({
  schema: './src/database/schema.ts',
  out: './drizzle/migrations',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});

Com isso configurado, você gera migrações com npx drizzle-kit generate e as aplica com npx drizzle-kit migrate. Cada migração é um arquivo SQL puro que você pode inspecionar antes de aplicar.

Exemplo prático: CRUD completo de usuários

Vamos construir um módulo de usuários completo para demonstrar o uso real do Drizzle dentro do NestJS.

Service com queries tipadas

TYPESCRIPT
// src/users/users.service.ts
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { eq, ilike, and, desc, sql } from 'drizzle-orm';
import { DRIZZLE, DrizzleDatabase } from '../database/database.module';
import { users, posts } from '../database/schema';
 
interface CreateUserDto {
  email: string;
  name: string;
  role?: string;
}
 
interface ListUsersParams {
  search?: string;
  page: number;
  limit: number;
}
 
@Injectable()
export class UsersService {
  constructor(
    @Inject(DRIZZLE) private readonly db: DrizzleDatabase,
  ) {}
 
  async create(dto: CreateUserDto) {
    const [user] = await this.db
      .insert(users)
      .values({
        email: dto.email,
        name: dto.name,
        role: dto.role ?? 'user',
      })
      .returning();
 
    return user;
  }
 
  async findById(id: string) {
    const user = await this.db.query.users.findFirst({
      where: eq(users.id, id),
      with: {
        posts: {
          where: eq(posts.published, true),
          orderBy: desc(posts.createdAt),
          limit: 10,
        },
      },
    });
 
    if (!user) {
      throw new NotFoundException(`Usuário com id ${id} não encontrado`);
    }
 
    return user;
  }
 
  async list(params: ListUsersParams) {
    const { search, page, limit } = params;
    const offset = (page - 1) * limit;
 
    const conditions = [eq(users.isActive, true)];
 
    if (search) {
      conditions.push(ilike(users.name, `%${search}%`));
    }
 
    const [data, countResult] = await Promise.all([
      this.db
        .select()
        .from(users)
        .where(and(...conditions))
        .orderBy(desc(users.createdAt))
        .limit(limit)
        .offset(offset),
 
      this.db
        .select({ total: sql<number>`count(*)::int` })
        .from(users)
        .where(and(...conditions)),
    ]);
 
    return {
      data,
      total: countResult[0].total,
      page,
      limit,
      totalPages: Math.ceil(countResult[0].total / limit),
    };
  }
 
  async updateEmail(id: string, newEmail: string) {
    const [updated] = await this.db
      .update(users)
      .set({
        email: newEmail,
        updatedAt: new Date(),
      })
      .where(eq(users.id, id))
      .returning();
 
    if (!updated) {
      throw new NotFoundException(`Usuário com id ${id} não encontrado`);
    }
 
    return updated;
  }
 
  async softDelete(id: string) {
    const [deactivated] = await this.db
      .update(users)
      .set({ isActive: false, updatedAt: new Date() })
      .where(eq(users.id, id))
      .returning();
 
    if (!deactivated) {
      throw new NotFoundException(`Usuário com id ${id} não encontrado`);
    }
 
    return deactivated;
  }
}

Esse service demonstra vários padrões importantes: queries com filtros dinâmicos, paginação com contagem paralela, carregamento de relações via query API e soft delete. Tudo com tipagem completa: se você tentar acessar uma coluna que não existe, o TypeScript acusa erro em tempo de compilação.

Controller correspondente

TYPESCRIPT
// src/users/users.controller.ts
import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  ParseUUIDPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
 
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
 
  @Post()
  create(@Body() body: { email: string; name: string; role?: string }) {
    return this.usersService.create(body);
  }
 
  @Get()
  list(
    @Query('search') search?: string,
    @Query('page') page = '1',
    @Query('limit') limit = '20',
  ) {
    return this.usersService.list({
      search,
      page: parseInt(page, 10),
      limit: parseInt(limit, 10),
    });
  }
 
  @Get(':id')
  findById(@Param('id', ParseUUIDPipe) id: string) {
    return this.usersService.findById(id);
  }
 
  @Patch(':id/email')
  updateEmail(
    @Param('id', ParseUUIDPipe) id: string,
    @Body('email') email: string,
  ) {
    return this.usersService.updateEmail(id, email);
  }
 
  @Delete(':id')
  softDelete(@Param('id', ParseUUIDPipe) id: string) {
    return this.usersService.softDelete(id);
  }
}

Boas práticas para Drizzle com NestJS em produção

Organize o schema por domínio

Em projetos maiores, um único arquivo de schema se torna inviável. Separe por domínio e use um arquivo index para reexportar:

TYPESCRIPT
// src/database/schema/index.ts
export * from './users.schema';
export * from './posts.schema';
export * from './comments.schema';

Use transações para operações compostas

O Drizzle suporta transações de forma direta. Sempre que uma operação envolve múltiplas escritas que devem ser atômicas, use db.transaction:

TYPESCRIPT
async createUserWithFirstPost(userData: CreateUserDto, postData: CreatePostDto) {
  return this.db.transaction(async (tx) => {
    const [user] = await tx.insert(users).values(userData).returning();
    const [post] = await tx.insert(posts).values({
      ...postData,
      authorId: user.id,
    }).returning();
 
    return { user, post };
  });
}

Evite expor a instância do Drizzle diretamente nos controllers

Mantenha o acesso ao banco restrito aos services e repositories. Isso facilita testes unitários (você mocka o service, não o banco) e preserva a separação de responsabilidades que o NestJS favorece.

Crie tipos auxiliares a partir do schema

O Drizzle permite inferir tipos de insert e select diretamente do schema, evitando duplicação:

TYPESCRIPT
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
import { users } from './schema';
 
export type User = InferSelectModel<typeof users>;
export type NewUser = InferInsertModel<typeof users>;

Monitore suas queries

Em produção

Guias Relacionados

Explore mais guias técnicos

Mais de 100 guias práticos de integração entre frameworks, ferramentas e plataformas.

Ver todos os guias