Repository pattern tipado
1. Fundamentos do Repository Pattern em TypeScript
O Repository Pattern é um padrão arquitetural que abstrai o acesso a dados, fornecendo uma interface consistente para operações de persistência. Em TypeScript, esse padrão ganha ainda mais poder com o sistema de tipos, permitindo que contratos sejam definidos em nível de tipo e verificados em tempo de compilação.
Benefícios no domínio TypeScript:
- Type safety: erros de tipo são capturados antes do runtime
- IntelliSense: IDEs oferecem autocomplete preciso
- Desacoplamento: a lógica de negócio não depende da implementação de persistência
- Testabilidade: mocks tipados podem substituir repositórios reais
Repositórios genéricos vs específicos:
- Genéricos: operações CRUD comuns para qualquer entidade (úteis para prototipagem)
- Específicos: métodos customizados para regras de negócio particulares (recomendados para produção)
Contratos com interfaces:
interface IRepository<T, ID> {
findById(id: ID): Promise<T | null>;
findAll(): Promise<T[]>;
create(entity: Omit<T, 'id'>): Promise<T>;
update(id: ID, partial: Partial<T>): Promise<T | null>;
delete(id: ID): Promise<boolean>;
}
2. Construindo um Repositório Genérico com Tipos Fortes
Vamos definir uma entidade base e criar um repositório genérico tipado:
interface Entity {
id: string;
createdAt: Date;
updatedAt: Date;
}
interface IRepository<T extends Entity> {
findById(id: T['id']): Promise<T | null>;
findAll(): Promise<T[]>;
create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T>;
update(id: T['id'], data: Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>): Promise<T | null>;
delete(id: T['id']): Promise<boolean>;
}
Implementação concreta in-memory:
class InMemoryRepository<T extends Entity> implements IRepository<T> {
protected items: T[] = [];
async findById(id: T['id']): Promise<T | null> {
return this.items.find(item => item.id === id) || null;
}
async findAll(): Promise<T[]> {
return [...this.items];
}
async create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T> {
const now = new Date();
const entity = {
...data,
id: crypto.randomUUID(),
createdAt: now,
updatedAt: now,
} as T;
this.items.push(entity);
return entity;
}
async update(id: T['id'], data: Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>): Promise<T | null> {
const index = this.items.findIndex(item => item.id === id);
if (index === -1) return null;
this.items[index] = {
...this.items[index],
...data,
updatedAt: new Date(),
};
return this.items[index];
}
async delete(id: T['id']): Promise<boolean> {
const index = this.items.findIndex(item => item.id === id);
if (index === -1) return false;
this.items.splice(index, 1);
return true;
}
}
3. Abstraindo Fontes de Dados com Tipos
Repositório com Prisma (banco relacional):
import { PrismaClient, Prisma } from '@prisma/client';
class PrismaUserRepository implements IRepository<User> {
private prisma = new PrismaClient();
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
async create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
return this.prisma.user.create({ data });
}
// ... demais métodos
}
Repositório com API externa:
interface ApiResponse<T> {
data: T | null;
error: string | null;
statusCode: number;
}
class ExternalApiRepository<T extends Entity> implements IRepository<T> {
constructor(private baseUrl: string, private endpoint: string) {}
async findById(id: string): Promise<T | null> {
try {
const response = await fetch(`${this.baseUrl}/${this.endpoint}/${id}`);
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
// ... demais métodos com tratamento de erro tipado
}
4. Operações Avançadas e Query Objects Tipados
Query objects permitem consultas complexas com type safety:
interface Query<T> {
where?: Partial<T>;
orderBy?: {
field: keyof T;
direction: 'asc' | 'desc';
};
limit?: number;
offset?: number;
include?: string[];
}
interface IQueryableRepository<T extends Entity> extends IRepository<T> {
findByCriteria(query: Query<T>): Promise<T[]>;
count(query: Query<T>): Promise<number>;
}
Implementação com encadeamento type-safe:
class AdvancedInMemoryRepository<T extends Entity>
extends InMemoryRepository<T>
implements IQueryableRepository<T> {
async findByCriteria(query: Query<T>): Promise<T[]> {
let result = [...this.items];
if (query.where) {
result = result.filter(item =>
Object.entries(query.where!).every(([key, value]) =>
item[key as keyof T] === value
)
);
}
if (query.orderBy) {
const { field, direction } = query.orderBy;
result.sort((a, b) => {
const aVal = a[field];
const bVal = b[field];
if (aVal < bVal) return direction === 'asc' ? -1 : 1;
if (aVal > bVal) return direction === 'asc' ? 1 : -1;
return 0;
});
}
if (query.offset) result = result.slice(query.offset);
if (query.limit) result = result.slice(0, query.limit);
return result;
}
async count(query: Query<T>): Promise<number> {
const result = await this.findByCriteria(query);
return result.length;
}
}
5. Injeção de Dependência e Inversão de Controle
Usando tsyringe para IoC:
import { injectable, inject, container } from 'tsyringe';
@injectable()
class UserService {
constructor(
@inject('UserRepository')
private userRepo: IRepository<User>
) {}
async getUser(id: string): Promise<User | null> {
return this.userRepo.findById(id);
}
}
// Configuração do container
container.register<IRepository<User>>('UserRepository', {
useClass: PrismaUserRepository,
});
// Uso
const userService = container.resolve(UserService);
Para testes, substituímos a implementação:
// Teste unitário
const mockRepo: jest.Mocked<IRepository<User>> = {
findById: jest.fn(),
findAll: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
container.registerInstance('UserRepository', mockRepo);
6. Tratamento de Erros e Resultados Monádicos
Pattern Result para operações de repositório:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
class NotFoundError extends Error {
constructor(entity: string, id: string) {
super(`${entity} with id ${id} not found`);
this.name = 'NotFoundError';
}
}
class DatabaseError extends Error {
constructor(operation: string, cause?: unknown) {
super(`Database error during ${operation}`);
this.name = 'DatabaseError';
this.cause = cause;
}
}
interface ISafeRepository<T extends Entity> {
findById(id: T['id']): Promise<Result<T, NotFoundError | DatabaseError>>;
create(data: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<Result<T, DatabaseError>>;
}
Implementação segura:
class SafePrismaUserRepository implements ISafeRepository<User> {
async findById(id: string): Promise<Result<User, NotFoundError | DatabaseError>> {
try {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return { success: false, error: new NotFoundError('User', id) };
}
return { success: true, data: user };
} catch (err) {
return { success: false, error: new DatabaseError('findById', err) };
}
}
}
// Uso com discriminated union
const result = await userRepo.findById('123');
if (result.success) {
console.log(result.data.name);
} else {
if (result.error instanceof NotFoundError) {
// tratar 404
} else {
// tratar erro de banco
}
}
7. Casos de Uso Reais e Boas Práticas
Exemplo completo: repositório de User com operações customizadas:
interface User extends Entity {
email: string;
name: string;
isActive: boolean;
lastLogin?: Date;
}
interface IUserRepository extends IRepository<User> {
findByEmail(email: string): Promise<User | null>;
findActiveUsers(): Promise<User[]>;
updateLastLogin(id: string): Promise<void>;
}
class UserRepository extends PrismaRepository<User> implements IUserRepository {
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
async findActiveUsers(): Promise<User[]> {
return this.prisma.user.findMany({ where: { isActive: true } });
}
async updateLastLogin(id: string): Promise<void> {
await this.prisma.user.update({
where: { id },
data: { lastLogin: new Date() },
});
}
}
CQRS leve: separação leitura/escrita:
interface IUserReadRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
searchByName(query: string): Promise<User[]>;
}
interface IUserWriteRepository {
create(data: CreateUserDTO): Promise<User>;
update(id: string, data: UpdateUserDTO): Promise<User>;
deactivate(id: string): Promise<void>;
}
Benefícios práticos do tipo em tempo de compilação:
// Erro detectado em compilação:
const userRepo: IUserRepository = new UserRepository();
userRepo.create({
email: 'test@test.com',
// name está faltando - erro de compilação!
});
O sistema de tipos do TypeScript transforma o Repository Pattern em uma ferramenta poderosa para construir aplicações robustas, testáveis e de fácil manutenção. A combinação de generics, discriminated unions e injeção de dependência permite criar abstrações que protegem contra erros comuns em tempo de compilação, antes mesmo do código ser executado.
Referências
- TypeScript Handbook: Generics — Documentação oficial sobre generics, base para criar repositórios tipados reutilizáveis
- Repository Pattern - Martin Fowler — Definição clássica do padrão Repository por Martin Fowler
- Prisma Documentation: Repository Pattern — Como implementar repositórios tipados com Prisma ORM
- tsyringe: Dependency Injection for TypeScript — Container IoC leve da Microsoft para injeção de dependência em TypeScript
- Result Pattern in TypeScript — Artigo detalhado sobre o padrão Result para tratamento de erros em TypeScript
- TypeScript Deep Dive: Discriminated Unions — Guia avançado sobre discriminated unions, essencial para implementar Results tipados
- CQRS Pattern with TypeScript — Documentação da Microsoft sobre o padrão CQRS, aplicável a repositórios de leitura e escrita