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.
// Terminal
// npm install drizzle-orm postgres
// npm install -D drizzle-kit @types/nodeO 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:
// 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:
// 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
// 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
// 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
// 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:
// 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:
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:
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