Home > Blog > Desenvolvimento Web
Desenvolvimento Web
JavaScript
Programação

Repository Pattern no Node.js

Atualizado em: 2 de setembro de 2026

Estação de trabalho usada no desenvolvimento de aplicações Node.js

O Repository Pattern no Node.js separa as regras de negócio dos detalhes de persistência. Em vez de serviços conhecerem SQL, coleções do MongoDB ou métodos específicos de um ORM, eles trabalham com uma interface que representa operações do domínio, como buscar um pedido, salvar um usuário ou listar produtos disponíveis.

Essa separação melhora testes, reduz acoplamento e facilita trocar tecnologia de banco sem espalhar mudanças pela aplicação. Porém, um repository mal desenhado pode apenas esconder o ORM, duplicar APIs genéricas e criar uma camada sem valor. O objetivo não é criar mais arquivos, mas proteger o domínio de detalhes externos.

Neste guia, você aprenderá a definir contratos, implementar repositories com PostgreSQL, usar transações, paginação, filtros, mapeamento, testes, injeção de dependência e boas práticas para produção.

O que é Repository Pattern?

Repository é um objeto que fornece uma visão orientada ao domínio sobre uma fonte de dados. Para a camada de aplicação, ele se comporta como uma coleção especializada, mesmo que por trás use SQL, HTTP, cache ou outro mecanismo.

A ideia foi popularizada em padrões de arquitetura corporativa e Domain-Driven Design. O ponto central é manter a regra de negócio dependente de abstrações estáveis, não de bibliotecas específicas.

Para entender a composição dessas dependências, consulte Injeção de Dependência no Node.js. Para operações atômicas, veja Transações PostgreSQL no Node.js.

Problema sem repository

async function approveOrder(orderId) {
  const result = await pool.query(
    'SELECT * FROM orders WHERE id = $1',
    [orderId]
  );

  const order = result.rows[0];

  if (!order) throw new Error('Pedido não encontrado');
  if (order.status !== 'pending') {
    throw new Error('Pedido não pode ser aprovado');
  }

  await pool.query(
    'UPDATE orders SET status = $1 WHERE id = $2',
    ['approved', orderId]
  );
}

A função mistura regra, SQL, nomes de colunas, tratamento de ausência e atualização. Isso dificulta testes unitários e reutilização.

Contrato do repository

class OrderRepository {
  async findById(id) {
    throw new Error('Not implemented');
  }

  async save(order) {
    throw new Error('Not implemented');
  }
}

Em TypeScript, prefira uma interface:

export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

O contrato usa tipos do domínio, não linhas do banco.

Serviço de aplicação

export class ApproveOrder {
  constructor(
    private readonly orders: OrderRepository
  ) {}

  async execute(orderId: string): Promise<void> {
    const order = await this.orders.findById(orderId);

    if (!order) {
      throw new OrderNotFoundError(orderId);
    }

    order.approve();
    await this.orders.save(order);
  }
}

O serviço conhece a intenção do domínio. SQL, pool e ORM ficam fora dessa camada.

Entidade de domínio

export class Order {
  constructor(
    readonly id: string,
    private status: 'pending' | 'approved' | 'cancelled',
    private version: number
  ) {}

  approve(): void {
    if (this.status !== 'pending') {
      throw new InvalidOrderStatusError();
    }

    this.status = 'approved';
  }

  snapshot() {
    return {
      id: this.id,
      status: this.status,
      version: this.version
    };
  }
}

A entidade mantém invariantes e não expõe setters genéricos.

Implementação PostgreSQL

export class PgOrderRepository implements OrderRepository {
  constructor(private readonly db: Queryable) {}

  async findById(id: string): Promise<Order | null> {
    const result = await this.db.query(`
      SELECT id, status, version
      FROM orders
      WHERE id = $1
    `, [id]);

    if (result.rowCount === 0) return null;
    return OrderMapper.toDomain(result.rows[0]);
  }

  async save(order: Order): Promise<void> {
    const data = order.snapshot();

    const result = await this.db.query(`
      UPDATE orders
      SET status = $1,
          version = version + 1,
          updated_at = now()
      WHERE id = $2
        AND version = $3
    `, [data.status, data.id, data.version]);

    if (result.rowCount === 0) {
      throw new OptimisticLockError(data.id);
    }
  }
}

O tipo Queryable pode ser um pool ou client transacional.

Mapper separado

export const OrderMapper = {
  toDomain(row: OrderRow): Order {
    return new Order(
      String(row.id),
      row.status,
      row.version
    );
  },

  toPersistence(order: Order) {
    return order.snapshot();
  }
};

O mapper impede que nomes e tipos do banco vazem para o domínio.

Não retorne linhas cruas

Uma linha pode usar snake_case, valores nullable e tipos convertidos pelo driver. Retornar esse objeto diretamente acopla todas as camadas ao schema.

Repository por agregado

Crie repositories para agregados importantes, como OrderRepository e CustomerRepository. Evite uma interface genérica para cada tabela.

Repository genérico

interface Repository<T> {
  findById(id: string): Promise<T | null>;
  create(entity: T): Promise<void>;
  update(entity: T): Promise<void>;
  delete(id: string): Promise<void>;
}

Esse formato parece reutilizável, mas frequentemente ignora necessidades reais. Um pedido pode precisar de findPendingByCustomer, enquanto um usuário precisa de findByEmail. Contratos específicos comunicam melhor a intenção.

Métodos orientados ao domínio

findPendingByCustomer(customerId: string)
findExpiringReservations(limit: number)
existsByEmail(email: string)

O nome deve descrever a pergunta de negócio, não uma combinação arbitrária de filtros.

Filtros complexos

Para telas administrativas, um objeto de consulta pode ser adequado:

type OrderSearch = {
  status?: OrderStatus;
  customerId?: string;
  createdAfter?: Date;
  cursor?: string;
  limit: number;
};

Valide limites e normalize datas antes de enviar ao repository.

Paginação

type Page<T> = {
  items: T[];
  nextCursor: string | null;
};

Para filas grandes, paginação por cursor costuma ser mais estável que offset. Consulte Paginação em APIs Node.js.

Ordenação

Não aceite o nome de coluna diretamente do usuário. Mapeie opções permitidas:

const ORDER_FIELDS = {
  createdAt: 'created_at',
  total: 'total_cents'
};

Isso evita SQL injection em identificadores.

Transações

O repository não deve abrir uma transação independente para cada método. A unidade de negócio pode envolver vários repositories.

await withTransaction(pool, async client => {
  const orders = new PgOrderRepository(client);
  const inventory = new PgInventoryRepository(client);

  await useCase.execute({ orders, inventory });
});

Todos usam o mesmo client.

Unit of Work

Uma Unit of Work pode agrupar repositories e transação:

interface UnitOfWork {
  orders: OrderRepository;
  inventory: InventoryRepository;
  commit(): Promise<void>;
  rollback(): Promise<void>;
}

Use somente quando a complexidade justificar. Um helper transacional simples é suficiente em muitas aplicações.

Inserção

async add(order: Order): Promise<void> {
  const data = order.snapshot();

  await this.db.query(`
    INSERT INTO orders (id, status, version)
    VALUES ($1, $2, $3)
  `, [data.id, data.status, data.version]);
}

Diferenciar add e save torna explícito se o registro deve existir.

Upsert com cuidado

Upsert pode esconder conflito de criação e atualização. Use quando a semântica realmente for “criar ou substituir”, não apenas para evitar tratar erros.

Exclusão

Domínios regulados frequentemente usam soft delete ou status. O repository deve refletir a regra, por exemplo archive(), em vez de expor delete() indiscriminadamente.

Cache

Um decorator pode adicionar cache sem alterar o contrato:

class CachedOrderRepository implements OrderRepository {
  constructor(
    private readonly inner: OrderRepository,
    private readonly cache: Cache
  ) {}

  async findById(id: string) {
    const cached = await this.cache.get(`order:${id}`);
    if (cached) return OrderMapper.fromCache(cached);

    const order = await this.inner.findById(id);
    if (order) await this.cache.set(`order:${id}`, order.snapshot());
    return order;
  }

  async save(order: Order) {
    await this.inner.save(order);
    await this.cache.delete(`order:${order.id}`);
  }
}

Defina política de invalidação antes de ativar cache.

Chamadas HTTP como repository

Uma implementação pode consultar outro serviço. O contrato permanece, mas deve tratar timeout, retry e circuit breaker. Consulte Circuit Breaker no Node.js.

Repository e ORM

ORM não elimina a necessidade de limites arquiteturais. O repository pode usar Prisma, Drizzle, Sequelize ou TypeORM internamente, evitando que modelos ativos atravessem a aplicação.

Evite wrappers sem valor

findAll() { return prisma.order.findMany(); }
findOne(id) { return prisma.order.findUnique({ where: { id } }); }

Se a camada apenas renomeia métodos, talvez não agregue isolamento. Adicione mapeamento, contratos de domínio e políticas relevantes.

Teste unitário com fake

class InMemoryOrderRepository implements OrderRepository {
  private items = new Map<string, Order>();

  async findById(id: string) {
    return this.items.get(id) ?? null;
  }

  async save(order: Order) {
    this.items.set(order.id, order);
  }
}

O caso de uso pode ser testado sem banco.

Fake não substitui integração

Um Map não reproduz constraints, transações, tipos, locks ou SQL. Teste a implementação PostgreSQL em banco real.

Testes de contrato

Execute a mesma suíte contra o fake e o repository real:

  • retorna null quando ausente;
  • salva e recupera;
  • mantém ordenação;
  • respeita paginação;
  • detecta versão antiga;
  • mapeia tipos corretamente;
  • não retorna dados de outro tenant.

Multi-tenancy

O tenant deve participar de todas as consultas. Uma interface segura pode receber o tenant no construtor, evitando esquecer filtros.

Consulte Multi-Tenancy no Node.js e Row-Level Security no Node.js.

Observabilidade

Meça duração, erros, timeouts e quantidade de linhas. Não registre parâmetros sensíveis. Use nomes de operação como orders.findById.

Erros de persistência

Converta erros conhecidos:

  • violação unique em EmailAlreadyExistsError;
  • conflito de versão em OptimisticLockError;
  • timeout em erro de dependência;
  • registro ausente em null ou erro definido pelo contrato.

Não esconda toda falha como “erro desconhecido”.

Boas práticas

  • Defina contratos por agregado.
  • Use tipos do domínio.
  • Separe mapper.
  • Mantenha SQL na infraestrutura.
  • Não abra transações internas desnecessárias.
  • Evite repository genérico universal.
  • Valide paginação e ordenação.
  • Use testes de contrato.
  • Proteja tenant em todas as consultas.
  • Monitore operações lentas.

Conclusão

O Repository Pattern no Node.js cria uma fronteira entre domínio e persistência. Casos de uso passam a trabalhar com operações significativas, enquanto SQL, ORM, cache e serviços remotos ficam encapsulados.

O padrão traz valor quando o contrato representa o domínio e a implementação faz mapeamento, controle de concorrência e políticas de acesso. Com repositories específicos, transações compartilhadas e testes de contrato, a aplicação ganha flexibilidade sem transformar cada consulta em uma abstração artificial.

10 melhores cursos de programação em 2026

Descubra os melhores cursos de programação. Aprenda a escolher o curso ideal para iniciar ou avançar na carreira de desenvolvedor

POSTS RELACIONADOS

Ver todos

Seta para a direita