Acesso a banco de dados no Node.js: escolhas de ORM/Query Builder e design de repositórios

Capítulo 11

Tempo estimado de leitura: 11 minutos

+ Exercício

Abordagens de acesso a dados: ORM vs Query Builder

Em um back-end Node.js com Express e TypeScript, o acesso a banco de dados costuma cair em duas famílias de ferramentas:

  • ORM (Object-Relational Mapper): você trabalha com entidades/objetos e o ORM traduz para SQL.
  • Query Builder: você monta consultas de forma programática (ou SQL “quase direto”), com mais controle sobre o que será executado.

A escolha impacta produtividade, controle de SQL, migrações e desempenho. O ponto central é: o domínio (regras de negócio) não deve depender da ferramenta. Por isso, além de escolher ORM/Query Builder, você precisa de um design de repositórios que desacople a aplicação da infraestrutura.

Critérios práticos de comparação

CritérioORMQuery Builder
ProdutividadeAlta para CRUD e relacionamentos; menos código repetitivoBoa, mas exige mais atenção na montagem de consultas e mapeamento
Controle de SQLMédio; pode gerar SQL inesperado em casos complexosAlto; você controla joins, selects, índices e hints (quando suportado)
MigraçõesGeralmente integrado (migrations, schema sync em alguns)Geralmente integrado também (migrations/seed), mas sem “entidades” automáticas
DesempenhoBom em muitos casos, mas pode sofrer com N+1, eager/lazy loading e queries grandesExcelente para consultas específicas e otimização; menos “mágica”
Tipagem TypeScriptBoa, mas pode ficar indireta (decorators, metadata, proxies)Muito boa quando o builder oferece inferência; senão, você tipa DTOs/rows
Casos complexosÀs vezes exige SQL bruto ou extensõesNatural para SQL avançado, CTEs, agregações, janelas

Regra prática: se o seu sistema tem muitas consultas específicas e preocupação forte com SQL e performance, um Query Builder tende a ser mais previsível. Se o foco é rapidez com modelo relacional e operações comuns, um ORM pode acelerar bastante — desde que você monitore e revise as queries geradas.

Design de repositórios: desacoplando domínio da infraestrutura

Um repositório é uma camada que expõe operações de persistência em termos do domínio (ex.: findByEmail, search, save), escondendo detalhes do banco, SQL, tabelas e bibliotecas.

Estrutura sugerida de pastas

src/ domain/   users/     User.ts     UserRepository.ts     UserService.ts  infra/   db/     pool.ts     migrations/   users/     UserRepository.pg.ts  app/   http/     users.routes.ts
  • domain/: entidades, interfaces e serviços de domínio (não importam nada de banco).
  • infra/: implementações concretas (Postgres, MySQL, etc.).
  • app/: camada HTTP (controllers/rotas), chamando serviços.

Interface do repositório no domínio

Defina contratos orientados a casos de uso, não a tabelas. Evite expor detalhes como “colunas” diretamente.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

// src/domain/users/User.ts export type UserId = string; export interface User {   id: UserId;   name: string;   email: string;   createdAt: Date; } // src/domain/users/UserRepository.ts export type UserSearchFilters = {   q?: string;   createdFrom?: Date;   createdTo?: Date; }; export type Pagination = { page: number; pageSize: number }; export type Paginated<T> = { items: T[]; total: number; page: number; pageSize: number }; export interface UserRepository {   findById(id: UserId): Promise<User | null>;   findByEmail(email: string): Promise<User | null>;   search(filters: UserSearchFilters, pagination: Pagination): Promise<Paginated<User>>;   create(data: Omit<User, 'id' | 'createdAt'>): Promise<User>;   updateName(id: UserId, name: string): Promise<void>;   delete(id: UserId): Promise<void>; }

Note que a interface não menciona SQL, tabelas, nem a biblioteca usada. Isso permite trocar ORM/Query Builder sem reescrever o domínio.

Gerenciamento de conexões, pool e lifecycle

Em Node.js, a prática recomendada para bancos relacionais é usar pool de conexões. Abrir uma conexão por request é caro e pode derrubar o banco sob carga.

Passo a passo: criando um pool (exemplo com PostgreSQL + pg)

// src/infra/db/pool.ts import { Pool } from 'pg'; export const pool = new Pool({   connectionString: process.env.DATABASE_URL,   max: Number(process.env.DB_POOL_MAX ?? 10),   idleTimeoutMillis: 30_000,   connectionTimeoutMillis: 5_000 });

Boas práticas:

  • max: limite de conexões simultâneas; ajuste conforme o banco e a carga.
  • idleTimeoutMillis: libera conexões ociosas.
  • connectionTimeoutMillis: evita ficar preso tentando conectar.

Lifecycle da aplicação: inicialização e encerramento

Garanta que o pool seja encerrado no shutdown para não deixar o processo “pendurado”.

// src/app/server.ts import express from 'express'; import { pool } from '../infra/db/pool'; const app = express(); app.use(express.json()); const server = app.listen(process.env.PORT ?? 3000); async function shutdown(signal: string) {   server.close(async () => {     await pool.end();     process.exit(0);   }); } process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM'));

Tratamento de falhas: timeouts, erros transitórios e consistência

Falhas comuns em acesso a dados:

  • Timeout (rede lenta, banco sobrecarregado)
  • Deadlock (transações concorrentes)
  • Violação de constraint (unique, foreign key)
  • Conexão caída (restart do banco)

Estratégias práticas:

  • Mapear erros para exceções de domínio/aplicação (ex.: email duplicado).
  • Retries com cuidado apenas para erros transitórios e operações idempotentes (ou dentro de transação bem definida).
  • Timeouts configurados no driver e, quando possível, no próprio banco (statement timeout).

Implementando repositórios com Query Builder (exemplo com SQL parametrizado)

A seguir, uma implementação concreta usando pg com SQL parametrizado (uma forma “manual”/builder simples). A ideia é mostrar organização, tipagem e operações além do CRUD.

Mapeamento de linha do banco para entidade

// src/infra/users/UserRepository.pg.ts import { pool } from '../db/pool'; import type { User, UserId } from '../../domain/users/User'; import type { Paginated, Pagination, UserRepository, UserSearchFilters } from '../../domain/users/UserRepository'; type UserRow = {   id: string;   name: string;   email: string;   created_at: Date; }; function mapRow(row: UserRow): User {   return {     id: row.id,     name: row.name,     email: row.email,     createdAt: new Date(row.created_at)   }; }

Implementação: find, create e update

export class PgUserRepository implements UserRepository {   async findById(id: UserId): Promise<User | null> {     const { rows } = await pool.query<UserRow>(       'select id, name, email, created_at from users where id = $1',       [id]     );     return rows[0] ? mapRow(rows[0]) : null;   }   async findByEmail(email: string): Promise<User | null> {     const { rows } = await pool.query<UserRow>(       'select id, name, email, created_at from users where email = $1',       [email]     );     return rows[0] ? mapRow(rows[0]) : null;   }   async create(data: { name: string; email: string }): Promise<User> {     const { rows } = await pool.query<UserRow>(       'insert into users (name, email) values ($1, $2) returning id, name, email, created_at',       [data.name, data.email]     );     return mapRow(rows[0]);   }   async updateName(id: UserId, name: string): Promise<void> {     await pool.query('update users set name = $1 where id = $2', [name, id]);   }   async delete(id: UserId): Promise<void> {     await pool.query('delete from users where id = $1', [id]);   }

Consultas além do CRUD: filtros e paginação

Paginação típica usa LIMIT/OFFSET. Para manter consistência, retorne também o total (para UI) e valide limites.

Passo a passo: construindo WHERE dinâmico com parâmetros

  async search(filters: UserSearchFilters, pagination: Pagination): Promise<Paginated<User>> {     const page = Math.max(1, pagination.page);     const pageSize = Math.min(100, Math.max(1, pagination.pageSize));     const offset = (page - 1) * pageSize;     const where: string[] = [];     const params: unknown[] = [];     const add = (sql: string, value: unknown) => {       params.push(value);       where.push(sql.replace('?', `$${params.length}`));     };     if (filters.q) {       add('(name ilike ? or email ilike ?)', `%${filters.q}%`);       params.push(`%${filters.q}%`);       where[where.length - 1] = where[where.length - 1].replace(`$${params.length - 1}`, `$${params.length - 1}`).replace(`$${params.length}`, `$${params.length}`);     }     if (filters.createdFrom) add('created_at >= ?', filters.createdFrom);     if (filters.createdTo) add('created_at <= ?', filters.createdTo);     const whereSql = where.length ? `where ${where.join(' and ')}` : '';     const countSql = `select count(*)::int as total from users ${whereSql}`;     const listSql = `select id, name, email, created_at from users ${whereSql} order by created_at desc limit $${params.length + 1} offset $${params.length + 2}`;     const countResult = await pool.query<{ total: number }>(countSql, params);     const listResult = await pool.query<UserRow>(listSql, [...params, pageSize, offset]);     return {       items: listResult.rows.map(mapRow),       total: countResult.rows[0]?.total ?? 0,       page,       pageSize     };   } }

Observações importantes:

  • Use sempre parâmetros para evitar SQL injection.
  • Imponha limites de pageSize para proteger o banco.
  • Para paginação em tabelas grandes, considere paginação por cursor (ex.: created_at + id) em vez de OFFSET.

Transações: garantindo atomicidade em operações compostas

Transações são essenciais quando você precisa que várias operações sejam aplicadas como uma unidade (tudo ou nada). Exemplo: criar usuário e registrar um evento/auditoria na mesma transação.

Passo a passo: helper de transação com client dedicado

// src/infra/db/transaction.ts import { pool } from './pool'; export async function withTransaction<T>(fn: (client: import('pg').PoolClient) => Promise<T>): Promise<T> {   const client = await pool.connect();   try {     await client.query('begin');     const result = await fn(client);     await client.query('commit');     return result;   } catch (err) {     await client.query('rollback');     throw err;   } finally {     client.release();   } }

Repositório com método transacional (além do CRUD)

// exemplo: criar usuário e inserir em user_audit na mesma transação import { withTransaction } from '../db/transaction'; export class PgUserRepository implements UserRepository {   // ...outros métodos   async createWithAudit(data: { name: string; email: string }, actorId: string): Promise<User> {     return withTransaction(async (client) => {       const userRes = await client.query<UserRow>(         'insert into users (name, email) values ($1, $2) returning id, name, email, created_at',         [data.name, data.email]       );       const user = mapRow(userRes.rows[0]);       await client.query(         'insert into user_audit (user_id, actor_id, action) values ($1, $2, $3)',         [user.id, actorId, 'USER_CREATED']       );       return user;     });   } }

Cuidados:

  • Dentro da transação, use sempre o mesmo client (não o pool diretamente).
  • Mantenha a transação curta: evite chamadas externas (HTTP, filas) dentro dela.

Como isso se traduz em ORM (sem acoplar o domínio)

Mesmo usando ORM, mantenha o contrato no domínio e implemente a interface na infraestrutura. O ORM vira um detalhe interno do repositório.

Exemplo de assinatura (padrão) para implementação com ORM

// src/infra/users/UserRepository.orm.ts import type { UserRepository } from '../../domain/users/UserRepository'; export class OrmUserRepository implements UserRepository {   constructor(/* injete o client do ORM aqui */) {}   async findById(id: string) { /* ... */ }   async findByEmail(email: string) { /* ... */ }   async search(filters, pagination) { /* ... */ }   async create(data) { /* ... */ }   async updateName(id: string, name: string) { /* ... */ }   async delete(id: string) { /* ... */ } }

Ao implementar search em ORM, procure recursos como:

  • Filtros composáveis (where dinâmico)
  • Paginação (limit/offset ou cursor)
  • Transações (callback transacional)
  • Carregamento de relações com cuidado para evitar N+1

Migrações e evolução de schema (prática recomendada)

Independente de ORM ou Query Builder, trate migrações como parte do ciclo de desenvolvimento:

  • Uma migração por mudança de schema (criar tabela, adicionar coluna, índice).
  • Evite “sincronizar schema automaticamente” em produção.
  • Inclua índices para colunas usadas em filtros e ordenações (ex.: email unique, created_at para ordenação).

Exemplo de SQL típico para suportar os métodos mostrados:

create table if not exists users (  id uuid primary key default gen_random_uuid(),  name text not null,  email text not null unique,  created_at timestamptz not null default now() ); create index if not exists users_created_at_idx on users (created_at desc);

Organização e tipagem: DTOs, validação e fronteiras

Para manter o código organizado:

  • Entidade de domínio representa o que a aplicação usa (User).
  • Row/DTO de banco representa como o banco retorna dados (UserRow com created_at).
  • Faça mapeamento explícito (mapRow) para evitar espalhar nomes de colunas pela aplicação.
  • Mantenha o repositório responsável por persistência; regras de negócio ficam em serviços de domínio.

Exemplo de serviço de domínio usando a interface

// src/domain/users/UserService.ts import type { UserRepository } from './UserRepository'; export class UserService {   constructor(private readonly users: UserRepository) {}   async register(input: { name: string; email: string }) {     const existing = await this.users.findByEmail(input.email);     if (existing) throw new Error('EMAIL_ALREADY_IN_USE');     return this.users.create({ name: input.name, email: input.email });   } }

Esse padrão permite testar o serviço com um repositório em memória (mock/fake) e trocar a implementação de banco sem alterar o domínio.

Agora responda o exercício sobre o conteúdo:

Ao aplicar o design de repositórios em um back-end Node.js com Express e TypeScript, qual prática ajuda a desacoplar o domínio da infraestrutura e facilitar a troca entre ORM e Query Builder?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Ao definir a interface do repositório no domínio com métodos alinhados ao caso de uso e implementar a persistência na infraestrutura, o domínio não depende de SQL nem da ferramenta, permitindo trocar ORM/Query Builder sem reescrever regras de negócio.

Próximo capitúlo

Migrations, seeds e transações: consistência de dados no back-end Node.js

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
69%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.