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.
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.
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.
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.
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.