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