Como Usar Banco de Dados com TypeScript: Prisma, TypeORM e Drizzle Comparados em Produção Já leu

Introdução ao Ecossistema de Banco de Dados em TypeScript TypeScript revolucionou a forma como desenvolvemos aplicações backend ao trazer tipagem estática para JavaScript. Quando o assunto é interação com banco de dados, três bibliotecas dominam o mercado: Prisma, TypeORM e Drizzle. Cada uma oferece uma abordagem diferente para resolver o mesmo problema: facilitar a comunicação entre sua aplicação TypeScript e o banco de dados, mantendo segurança de tipos e produtividade. O objetivo deste artigo é ajudá-lo a entender os fundamentos de cada ferramenta, suas filosofias de design e quando escolher uma em detrimento das outras. Não vamos apenas comparar números; vamos explorar como cada uma pensa diferentemente sobre o problema de persistência de dados, para que você possa tomar uma decisão informada baseada nas necessidades reais do seu projeto. Fundamentos: O que é uma ORM/Query Builder Diferença entre ORM e Query Builder Antes de mergulharmos nas três ferramentas, é crucial entender que nem todas são "ORMs" no sentido clássico. Uma

Introdução ao Ecossistema de Banco de Dados em TypeScript

TypeScript revolucionou a forma como desenvolvemos aplicações backend ao trazer tipagem estática para JavaScript. Quando o assunto é interação com banco de dados, três bibliotecas dominam o mercado: Prisma, TypeORM e Drizzle. Cada uma oferece uma abordagem diferente para resolver o mesmo problema: facilitar a comunicação entre sua aplicação TypeScript e o banco de dados, mantendo segurança de tipos e produtividade.

O objetivo deste artigo é ajudá-lo a entender os fundamentos de cada ferramenta, suas filosofias de design e quando escolher uma em detrimento das outras. Não vamos apenas comparar números; vamos explorar como cada uma pensa diferentemente sobre o problema de persistência de dados, para que você possa tomar uma decisão informada baseada nas necessidades reais do seu projeto.

Fundamentos: O que é uma ORM/Query Builder

Diferença entre ORM e Query Builder

Antes de mergulharmos nas três ferramentas, é crucial entender que nem todas são "ORMs" no sentido clássico. Uma ORM (Object-Relational Mapping) mapeia objetos da sua linguagem para tabelas do banco de dados, abstraindo completamente o SQL. Um Query Builder, por outro lado, oferece uma interface programática para construir queries SQL sem escrever SQL bruto, mas mantendo você mais próximo do banco.

O Prisma ocupa uma posição interessante: é uma ORM moderna que gera código e oferece um query builder integrado. O TypeORM é uma ORM tradicional inspirada no Hibernate (Java), com decoradores. O Drizzle é um query builder leve que se recusa a ser chamado de ORM. A escolha entre eles depende de quanto você quer se afastar do SQL e quanto de overhead você está disposto a aceitar.

Por que TypeScript muda o jogo

TypeScript elimina a maior dor de cabeça das ORMs tradicionais: tipos perdidos. Quando você faz uma query, o resultado é tipado automaticamente. Isso significa menos erros em produção, melhor autocompletar na IDE e documentação viva do seu schema. Todas as três ferramentas aproveitam isso, mas de formas diferentes.

Prisma: O Padrão Moderno

Arquitetura e Filosofia

O Prisma é a ferramenta mais jovem das três e representa uma mudança de paradigma. Ele não usa decoradores nem herança de classes; em vez disso, você define seu schema em um arquivo .prisma declarativo e a ferramenta gera um cliente tipado. O arquivo schema.prisma é a fonte única da verdade para seu modelo de dados.

A geração de código é fundamental no design do Prisma. Quando você roda prisma migrate dev, o Prisma não apenas cria a migration, mas também regenera o cliente com tipos inferidos do seu schema. Isso significa que seu código TypeScript sempre está sincronizado com o banco de dados.

Configuração e Exemplo Prático

Vamos começar instalando as dependências:

npm install @prisma/client
npm install -D prisma
npx prisma init

Primeiro, configure sua conexão no arquivo .env:

DATABASE_URL="postgresql://user:password@localhost:5432/myapp"

Agora, defina seu schema no prisma/schema.prisma:

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]

  @@map("users")
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  author    User    @relation(fields: [authorId], references: [id])
  authorId  Int

  @@map("posts")
}

Execute as migrations:

npx prisma migrate dev --name init

Agora você pode usar o cliente gerado:

// src/index.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // Criar usuário
  const user = await prisma.user.create({
    data: {
      email: 'alice@example.com',
      name: 'Alice',
      posts: {
        create: [
          { title: 'Hello World', published: true },
          { title: 'Draft Post', published: false }
        ]
      }
    },
    include: {
      posts: true
    }
  });

  console.log(user);

  // Query com type safety
  const userWithPosts = await prisma.user.findUnique({
    where: { id: user.id },
    include: {
      posts: {
        where: { published: true },
        orderBy: { id: 'desc' }
      }
    }
  });

  console.log(userWithPosts);

  // Atualizar
  await prisma.user.update({
    where: { id: user.id },
    data: { name: 'Alice Updated' }
  });

  // Deletar
  await prisma.post.deleteMany({
    where: { authorId: user.id }
  });

  await prisma.user.delete({
    where: { id: user.id }
  });
}

main()
  .catch(e => console.error(e))
  .finally(() => prisma.$disconnect());

Vantagens do Prisma

O cliente gerado oferece type safety absoluto. Se você tenta acessar um campo que não existe, o TypeScript reclama. Migrations são gerenciadas automaticamente e são seguras. O Prisma mantém histórico completo de mudanças no schema. A sintaxe é intuitiva e expressiva, com suporte robusto para relacionamentos complexos.

Desvantagens

O Prisma se recusa a suportar alguns padrões avançados de SQL, como queries muito complexas com múltiplas subqueries. Quando você precisa de queries raw, deve usar prisma.$queryRaw, que perde a segurança de tipos. A performance em operações em massa pode ser inferior a query builders puros, pois há overhead da abstração. Para projetos muito complexos com lógica SQL pesada, o Prisma pode ser limitante.

TypeORM: A ORM Tradicional com TypeScript

Arquitetura e Filosofia

O TypeORM segue o padrão clássico de ORM: você define entidades como classes com decoradores, e a biblioteca gerencia o mapeamento para o banco de dados. É fortemente inspirado no Hibernate (Java) e JPA, logo será familiar se você vem dessa experiência.

O TypeORM oferece mais controle sobre a estrutura das suas classes e permite herança, polimorfismo e padrões orientados a objetos mais complexos. Não há geração de código; você escreve tudo manualmente, o que significa menos "magia", mas mais responsabilidade.

Configuração e Exemplo Prático

Instale as dependências:

npm install typeorm reflect-metadata
npm install -D @types/node

Configure seu banco de dados e defina as entidades:

// src/database.ts
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { User } from './entities/User';
import { Post } from './entities/Post';

export const AppDataSource = new DataSource({
  type: 'postgres',
  host: 'localhost',
  port: 5432,
  username: 'user',
  password: 'password',
  database: 'myapp',
  synchronize: process.env.NODE_ENV === 'development',
  logging: true,
  entities: [User, Post],
  migrations: ['src/migrations/*.ts'],
  subscribers: ['src/subscribers/*.ts']
});

Defina as entidades:

// src/entities/User.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Post } from './Post';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ unique: true })
  email!: string;

  @Column({ nullable: true })
  name?: string;

  @OneToMany(() => Post, post => post.author, { eager: false })
  posts!: Post[];
}
// src/entities/Post.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './User';

@Entity('posts')
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  title!: string;

  @Column({ nullable: true })
  content?: string;

  @Column({ default: false })
  published!: boolean;

  @ManyToOne(() => User, user => user.posts, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'authorId' })
  author!: User;

  @Column()
  authorId!: number;
}

Agora use o repositório para operações:

// src/index.ts
import 'reflect-metadata';
import { AppDataSource } from './database';
import { User } from './entities/User';
import { Post } from './entities/Post';

async function main() {
  await AppDataSource.initialize();

  const userRepository = AppDataSource.getRepository(User);
  const postRepository = AppDataSource.getRepository(Post);

  // Criar usuário
  const user = new User();
  user.email = 'bob@example.com';
  user.name = 'Bob';

  const savedUser = await userRepository.save(user);

  // Criar posts
  const post1 = new Post();
  post1.title = 'Hello World';
  post1.published = true;
  post1.author = savedUser;

  const post2 = new Post();
  post2.title = 'Draft';
  post2.published = false;
  post2.author = savedUser;

  await postRepository.save([post1, post2]);

  // Query com eager loading
  const userWithPosts = await userRepository.findOne({
    where: { id: savedUser.id },
    relations: ['posts']
  });

  console.log(userWithPosts);

  // Query builder para lógica complexa
  const publishedPosts = await postRepository
    .createQueryBuilder('post')
    .leftJoinAndSelect('post.author', 'author')
    .where('post.published = :published', { published: true })
    .orderBy('post.id', 'DESC')
    .getMany();

  console.log(publishedPosts);

  // Atualizar
  await userRepository.update(savedUser.id, { name: 'Bob Updated' });

  // Deletar
  await userRepository.delete(savedUser.id);

  await AppDataSource.destroy();
}

main().catch(console.error);

Vantagens do TypeORM

TypeORM oferece flexibilidade máxima em design de entidades com suporte a herança, polimorfismo e patterns OOP avançados. O Query Builder é poderoso e permite construir queries complexas mantendo type safety. Oferece subscritores e hooks de ciclo de vida. Suporta múltiplos bancos de dados diferentes (MySQL, PostgreSQL, SQLite, etc.) de forma consistente.

Desvantagens

Há mais boilerplate: decoradores, configuração manual, sincronização de tipos entre classe e banco é manual. O Query Builder, apesar de poderoso, é mais verbose que o Prisma. A curva de aprendizado é mais acentuada. Migrations não são tão elegantes quanto no Prisma. Performance pode sofrer em casos com muitos decoradores e reflexão.

Drizzle ORM: O Query Builder Moderno

Arquitetura e Filosofia

Drizzle é o mais novo e radical na sua simplicidade. Não é uma ORM; é um query builder tipado que se recusa a abstrair demais do SQL. A premissa é clara: SQL é excelente, então vamos apenas adicionar type safety e ergonomia sem esconder a lógica.

Drizzle gera um arquivo de tipos baseado no seu schema, semelhante ao Prisma, mas oferece mais controle. Você está sempre próximo do SQL real, o que é poderoso para queries complexas. Suporta migrations versionadas e oferece excelente performance.

Configuração e Exemplo Prático

Instale as dependências:

npm install drizzle-orm postgres
npm install -D drizzle-kit

Configure seu schema em src/schema.ts:

// src/schema.ts
import { pgTable, serial, text, boolean, integer } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: text('email').unique().notNull(),
  name: text('name')
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content'),
  published: boolean('published').default(false).notNull(),
  authorId: integer('authorId')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' })
});

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts)
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id]
  })
}));

Configure o drizzle.config.ts:

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

export default {
  schema: './src/schema.ts',
  out: './migrations',
  driver: 'pg',
  dbCredentials: {
    connectionString: process.env.DATABASE_URL!
  }
} satisfies Config;

Gere as migrations:

npx drizzle-kit generate:pg
npx drizzle-kit migrate

Use o cliente:

// src/index.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { eq, and } from 'drizzle-orm';
import * as schema from './schema';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

const db = drizzle(pool, { schema });

async function main() {
  // Inserir usuário
  const [user] = await db
    .insert(schema.users)
    .values({
      email: 'charlie@example.com',
      name: 'Charlie'
    })
    .returning();

  console.log('User created:', user);

  // Inserir posts
  await db
    .insert(schema.posts)
    .values([
      { title: 'First Post', published: true, authorId: user.id },
      { title: 'Draft', published: false, authorId: user.id }
    ]);

  // Select com relacionamentos
  const userWithPosts = await db.query.users.findFirst({
    where: eq(schema.users.id, user.id),
    with: {
      posts: true
    }
  });

  console.log('User with posts:', userWithPosts);

  // Query mais complexa com WHERE e ORDER BY
  const publishedPosts = await db
    .select()
    .from(schema.posts)
    .where(
      and(
        eq(schema.posts.published, true),
        eq(schema.posts.authorId, user.id)
      )
    )
    .orderBy(schema.posts.id);

  console.log('Published posts:', publishedPosts);

  // Update
  await db
    .update(schema.users)
    .set({ name: 'Charlie Updated' })
    .where(eq(schema.users.id, user.id));

  // Delete
  await db
    .delete(schema.users)
    .where(eq(schema.users.id, user.id));

  process.exit(0);
}

main().catch(console.error);

Vantagens do Drizzle

Drizzle é extremamente leve e rápido. Não há reflexão pesada ou geração massiva de código. O schema é tipado nativamente, oferecendo type safety completo. Migrations são versionadas e versionáveis. Performance é excelente porque você está essencialmente escrevendo SQL com autocompletar. Oferece flexibilidade máxima para queries complexas sem compromissos.

Desvantagens

A curva de aprendizado é acentuada se você não conhece SQL bem. Relacionamentos são menos automáticos que em ORMs tradicionais; você precisa pensá-los explicitamente. Há menos abstração, então código que seria genérico no Prisma pode ser repetitivo no Drizzle. Comunidade menor significa menos recursos e exemplos.

Comparação Prática e Quando Usar Cada Uma

Matriz de Decisão

Para startups e MVPs: escolha Prisma. É rápido para prototipagem, migrations automáticas economizam tempo, e o cliente gerado oferece segurança com mínimo boilerplate.

Para aplicações empresariais complexas: escolha TypeORM. Se você precisa de padrões OOP avançados, herança de entidades, ou múltiplos bancos de dados, TypeORM oferece a flexibilidade necessária. O custo é mais boilerplate, mas a arquitetura fica mais robusta.

Para equipes com expertise SQL e projetos com queries pesadas: escolha Drizzle. Se seus dados são complexos e você precisa de queries otimizadas com subconsultas aninhadas, Drizzle oferece o melhor balance entre segurança de tipos e controle.

Exemplo de Mesma Query em Três Ferramentas

Cenário: buscar usuários com mais de 3 posts publicados e retornar usuário com posts, ordenado por data.

Prisma:

const users = await prisma.user.findMany({
  where: {
    posts: {
      some: { published: true }
    }
  },
  include: {
    posts: {
      where: { published: true },
      orderBy: { createdAt: 'desc' }
    }
  },
  take: 10
});

TypeORM:

const users = await userRepository
  .createQueryBuilder('user')
  .leftJoinAndSelect('user.posts', 'posts', 'posts.published = :published', { published: true })
  .where((qb) => {
    const subQuery = qb
      .subQuery()
      .select('COUNT(*)', 'count')
      .from(Post, 'p')
      .where('p.authorId = user.id AND p.published = :published', { published: true })
      .getQuery();
    return `(${subQuery}) > :minPosts`;
  }, { minPosts: 3 })
  .orderBy('posts.createdAt', 'DESC')
  .take(10)
  .getMany();

Drizzle:

const users = await db.query.users.findMany({
  where: (users, { inArray }) => {
    return inArray(
      users.id,
      db
        .select({ userId: posts.authorId })
        .from(posts)
        .where(eq(posts.published, true))
        .groupBy(posts.authorId)
        .having(sql`count(*) > 3`)
    );
  },
  with: {
    posts: {
      where: eq(posts.published, true),
      orderBy: posts.createdAt
    }
  },
  limit: 10
});

Conclusão

As três ferramentas resolvem o mesmo problema de formas distintas. Prisma é perfeito quando você quer desenvolvimento rápido com segurança de tipos automática e migrations gerenciadas. TypeORM brilha quando você precisa de arquitetura orientada a objetos sofisticada e flexibilidade em patterns de design. Drizzle é ideal quando você valoriza performance, controle fino sobre SQL e quer uma ferramenta que não se coloca no seu caminho.

Não existe a melhor ferramenta absoluta; existe a melhor ferramenta para seu projeto específico, sua equipe e seus requisitos. A recomendação profissional é: comece com Prisma em projetos novos, migre para TypeORM quando a complexidade exigir padrões OOP avançados, e considere Drizzle se performance em queries complexas se tornar crítica. Familiaridade com as três tornará você um desenvolvedor backend mais versátil em TypeScript.

Referências


Artigos relacionados