Voltar para Artigos
Back-end10 min de leitura

Isolando o Banco de Dados com Repository Pattern e TypeORM

Interface do repositório como Port, implementação TypeORM com DataSource injetado, Query Builder para consultas complexas, InMemoryRepository completo para testes unitários e como isolar a migração de ORM sem tocar no domínio.

12 de agosto de 2026

Quando o TypeORM vaza para os Use Cases — quando um Service importa getRepository(User) diretamente — você cria um acoplamento hard-coded ao ORM. Trocar o TypeORM por Prisma exige editar cada Use Case. Testar sem banco de dados real fica impossível. O código de negócio fica contaminado por conceitos de infraestrutura.

O Repository Pattern cria uma fronteira clara: Use Cases conhecem apenas a interface (o contrato), nunca a implementação. O TypeORM fica encapsulado na camada de infraestrutura. Quando você trocar o TypeORM por Prisma, edita apenas o arquivo de implementação — o domínio não muda.

A Interface (Port): O Contrato

A interface do repositório é TypeScript puro — sem nenhuma importação de ORM, framework ou biblioteca de infraestrutura. Ela vive na camada de domínio e define o que o Use Case precisa do banco de dados, não como isso é feito. Isso é o conceito de Port na Arquitetura Hexagonal: uma abstração que permite múltiplas implementações (TypeORM, Prisma, InMemory para testes) sem alterar o domínio.

IUsersRepository.ts
import type { User } from '../entities/User';

export interface CreateUserDTO {
  name: string;
  email: string;
  passwordHash: string;
  role?: 'USER' | 'MANAGER' | 'ADMIN';
}

export interface FindUsersParams {
  page?: number;
  pageSize?: number;
  search?: string;
  role?: 'USER' | 'MANAGER' | 'ADMIN';
  emailVerified?: boolean;
}

export interface PaginatedUsers {
  data: Array<User>;
  total: number;
  page: number;
  totalPages: number;
}

// Este arquivo não importa TypeORM, Prisma, Express — nada de infra
// Apenas TypeScript puro e tipos do domínio
export interface IUsersRepository {
  create(data: CreateUserDTO): Promise<User>;
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  findMany(params: FindUsersParams): Promise<PaginatedUsers>;
  save(user: User): Promise<User>;
  delete(id: string): Promise<void>;
  existsByEmail(email: string): Promise<boolean>;
}

Implementação com TypeORM

A implementação concreta com TypeORM fica isolada na camada de infraestrutura e recebe o DataSource via injeção de dependências. O DataSource injetado (ao invés de importar um singleton global) torna o repositório testável: em testes de integração, você pode injetar um DataSource apontando para um banco de testes isolado. O Query Builder é usado para consultas com filtros dinâmicos — mais seguro que concatenar strings SQL e mais flexível que o find() básico.

TypeORMUsersRepository.ts
import { DataSource, type Repository, ILike } from 'typeorm';
import { injectable, inject } from 'tsyringe';
import { UserModel } from '../models/UserModel';
import type {
  IUsersRepository,
  CreateUserDTO,
  FindUsersParams,
  PaginatedUsers,
} from '../../../domain/repositories/IUsersRepository';
import type { User } from '../../../domain/entities/User';

@injectable()
export class TypeORMUsersRepository implements IUsersRepository {
  private repo: Repository<UserModel>;

  constructor(
    // DataSource injetado — mais testável que dataSource global
    @inject('DataSource')
    dataSource: DataSource
  ) {
    this.repo = dataSource.getRepository(UserModel);
  }

  async create(data: CreateUserDTO): Promise<User> {
    const user = this.repo.create(data);
    return this.repo.save(user);
  }

  async findById(id: string): Promise<User | null> {
    return this.repo.findOneBy({ id });
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.repo.findOneBy({ email });
  }

  async existsByEmail(email: string): Promise<boolean> {
    return this.repo.existsBy({ email });
  }

  // Query Builder para consultas com filtros dinâmicos
  async findMany(params: FindUsersParams): Promise<PaginatedUsers> {
    const page = params.page ?? 1;
    const pageSize = params.pageSize ?? 20;

    const qb = this.repo.createQueryBuilder('user');

    if (params.search) {
      qb.andWhere('(user.name ILIKE :search OR user.email ILIKE :search)', {
        search: `%${params.search}%`,
      });
    }

    if (params.role) {
      qb.andWhere('user.role = :role', { role: params.role });
    }

    if (params.emailVerified !== undefined) {
      qb.andWhere('user.emailVerified = :emailVerified', {
        emailVerified: params.emailVerified,
      });
    }

    const [data, total] = await qb
      .orderBy('user.createdAt', 'DESC')
      .skip((page - 1) * pageSize)
      .take(pageSize)
      .getManyAndCount();

    return {
      data,
      total,
      page,
      totalPages: Math.ceil(total / pageSize),
    };
  }

  async save(user: User): Promise<User> {
    return this.repo.save(user);
  }

  async delete(id: string): Promise<void> {
    await this.repo.softDelete(id); // Soft delete — preserva o registro
  }
}

InMemoryRepository: Testes sem Banco Real

O InMemoryUsersRepository é onde o padrão realmente brilha. Como o Use Case depende apenas da interface IUsersRepository, podemos substituir a implementação TypeORM por uma implementação em memória sem que o Use Case perceba. Isso torna os testes unitários instantâneos e sem dependências externas. O array public items fica exposto propositalmente para que os testes possam inspecionar o estado interno após as operações.

InMemoryUsersRepository.ts
import type {
  IUsersRepository,
  CreateUserDTO,
  FindUsersParams,
  PaginatedUsers,
} from '../../domain/repositories/IUsersRepository';
import type { User } from '../../domain/entities/User';

export class InMemoryUsersRepository implements IUsersRepository {
  // Exposto publicamente para asserções nos testes
  public items: Array<User> = [];

  async create(data: CreateUserDTO): Promise<User> {
    const user: User = {
      id: crypto.randomUUID(),
      name: data.name,
      email: data.email,
      passwordHash: data.passwordHash,
      role: data.role ?? 'USER',
      emailVerified: false,
      avatarUrl: null,
      deletedAt: null,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    this.items.push(user);
    return user;
  }

  async findById(id: string): Promise<User | null> {
    return this.items.find((u) => u.id === id && !u.deletedAt) ?? null;
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.items.find((u) => u.email === email && !u.deletedAt) ?? null;
  }

  async existsByEmail(email: string): Promise<boolean> {
    return this.items.some((u) => u.email === email && !u.deletedAt);
  }

  async findMany(params: FindUsersParams): Promise<PaginatedUsers> {
    const page = params.page ?? 1;
    const pageSize = params.pageSize ?? 20;

    let filtered = this.items.filter((u) => !u.deletedAt);

    if (params.search) {
      const s = params.search.toLowerCase();
      filtered = filtered.filter(
        (u) => u.name.toLowerCase().includes(s) || u.email.toLowerCase().includes(s)
      );
    }

    if (params.role) {
      filtered = filtered.filter((u) => u.role === params.role);
    }

    const start = (page - 1) * pageSize;
    const data = filtered.slice(start, start + pageSize);

    return { data, total: filtered.length, page, totalPages: Math.ceil(filtered.length / pageSize) };
  }

  async save(user: User): Promise<User> {
    const index = this.items.findIndex((u) => u.id === user.id);
    if (index === -1) throw new Error(`User ${user.id} not found in InMemoryRepository`);
    this.items[index] = { ...user, updatedAt: new Date() };
    return this.items[index];
  }

  async delete(id: string): Promise<void> {
    const user = this.items.find((u) => u.id === id);
    if (user) user.deletedAt = new Date();
  }
}

O InMemoryRepository é o test double mais valioso da arquitetura: executa em microssegundos (sem I/O de rede), mantém o estado entre operações dentro do mesmo teste, e implementa os mesmos contratos que o repositório real. Um suite de 200 testes unitários com InMemory roda em menos de 2 segundos — enquanto com banco real levaria minutos.

Conclusão

O Repository Pattern como Port/Adapter garante que a lógica de negócio nunca dependa de detalhes de infraestrutura. O Use Case CreateUserUseCase funciona identicamente com TypeORM, Prisma ou InMemory — ele apenas chama this.usersRepo.create(dto). Quando a necessidade de trocar o ORM surgir, é uma cirurgia em um único arquivo de infraestrutura, não uma refatoração que toca dezenas de Use Cases.