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ério | ORM | Query Builder |
|---|---|---|
| Produtividade | Alta para CRUD e relacionamentos; menos código repetitivo | Boa, mas exige mais atenção na montagem de consultas e mapeamento |
| Controle de SQL | Médio; pode gerar SQL inesperado em casos complexos | Alto; você controla joins, selects, índices e hints (quando suportado) |
| Migrações | Geralmente integrado (migrations, schema sync em alguns) | Geralmente integrado também (migrations/seed), mas sem “entidades” automáticas |
| Desempenho | Bom em muitos casos, mas pode sofrer com N+1, eager/lazy loading e queries grandes | Excelente para consultas específicas e otimização; menos “mágica” |
| Tipagem TypeScript | Boa, 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ões | Natural 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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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
pageSizepara proteger o banco. - Para paginação em tabelas grandes, considere paginação por cursor (ex.:
created_at+id) em vez deOFFSET.
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.:
emailunique,created_atpara 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 (
UserRowcomcreated_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.