TypeScript

Repository Pattern com TypeScript: Abstrações e Implementações na Prática

14 min de leitura

Repository Pattern com TypeScript: Abstrações e Implementações na Prática

Repository Pattern com TypeScript: Abstrações e Implementações O Repository Pattern é um padrão de design que funciona como uma camada de abstração entre a lógica de negócio e a camada de acesso a dados. Seu objetivo é centralizar todas as operações de persistência, consulta e manipulação de dados em um único lugar, isolando a aplicação dos detalhes de implementação do banco de dados. Quando você trabalha com TypeScript, essa abstração ganha ainda mais força graças ao sistema de tipos robusto da linguagem, permitindo criar interfaces contratadas e reutilizáveis. Em termos práticos, o padrão funciona assim: em vez de sua camada de negócio chamar diretamente métodos do banco de dados (como queries SQL ou métodos de um ORM), você cria um repositório que encapsula essas chamadas. Isso significa que se você precisar trocar de banco de dados ou mudar a forma como os dados são recuperados, apenas o repositório muda, não toda sua aplicação. Além disso, fica muito mais fácil escrever

<h2>Repository Pattern com TypeScript: Abstrações e Implementações</h2>

<p>O Repository Pattern é um padrão de design que funciona como uma camada de abstração entre a lógica de negócio e a camada de acesso a dados. Seu objetivo é centralizar todas as operações de persistência, consulta e manipulação de dados em um único lugar, isolando a aplicação dos detalhes de implementação do banco de dados. Quando você trabalha com TypeScript, essa abstração ganha ainda mais força graças ao sistema de tipos robusto da linguagem, permitindo criar interfaces contratadas e reutilizáveis.</p>

<p>Em termos práticos, o padrão funciona assim: em vez de sua camada de negócio chamar diretamente métodos do banco de dados (como queries SQL ou métodos de um ORM), você cria um repositório que encapsula essas chamadas. Isso significa que se você precisar trocar de banco de dados ou mudar a forma como os dados são recuperados, apenas o repositório muda, não toda sua aplicação. Além disso, fica muito mais fácil escrever testes unitários, pois você pode criar mocks e stubs dos repositórios sem precisar de um banco de dados real.</p>

<h2>Entendendo a Abstração e a Interface</h2>

<h3>Por que Abstrair?</h3>

<p>A abstração é o coração do Repository Pattern. Sem ela, você teria repositórios concretos espalhados por toda a aplicação, cada um acoplado a uma tecnologia específica. Uma interface bem definida garante que qualquer implementação de repositório siga um contrato, independentemente de usar MongoDB, PostgreSQL, MySQL ou até um arquivo JSON. Isso é o que chamamos de inversão de controle: sua lógica de negócio não conhece os detalhes, apenas a interface.</p>

<h3>Criando uma Interface Genérica</h3>

<p>Vamos começar definindo uma interface genérica que serve como base para qualquer repositório:</p>

<pre><code class="language-typescript">export interface IRepository&lt;T&gt; {

create(entity: T): Promise&lt;T&gt;;

findById(id: string): Promise&lt;T | null&gt;;

findAll(): Promise&lt;T[]&gt;;

update(id: string, entity: Partial&lt;T&gt;): Promise&lt;T | null&gt;;

delete(id: string): Promise&lt;boolean&gt;;

}</code></pre>

<p>Esta interface usa generics, permitindo que qualquer tipo <code>T</code> seja persistido. Os métodos retornam <code>Promise</code> porque operações de banco de dados são assíncronas. Note que <code>findById</code> retorna <code>T | null</code> porque o registro pode não existir, e <code>update</code> aceita <code>Partial&lt;T&gt;</code> porque nem sempre você atualiza todos os campos.</p>

<p>Agora vamos criar uma entidade exemplo para trabalhar com:</p>

<pre><code class="language-typescript">export interface User {

id: string;

name: string;

email: string;

createdAt: Date;

}</code></pre>

<h2>Implementações Concretas do Padrão</h2>

<h3>Repositório com PostgreSQL</h3>

<p>A primeira implementação usa o Prisma, um ORM moderno e type-safe. Observe como a classe implementa a interface e encapsula completamente a interação com o banco:</p>

<pre><code class="language-typescript">import { PrismaClient } from &#039;@prisma/client&#039;;

import { IRepository } from &#039;./IRepository&#039;;

import { User } from &#039;./User&#039;;

export class PostgresUserRepository implements IRepository&lt;User&gt; {

constructor(private prisma: PrismaClient) {}

async create(entity: User): Promise&lt;User&gt; {

return this.prisma.user.create({

data: {

id: entity.id,

name: entity.name,

email: entity.email,

createdAt: entity.createdAt,

},

});

}

async findById(id: string): Promise&lt;User | null&gt; {

return this.prisma.user.findUnique({

where: { id },

});

}

async findAll(): Promise&lt;User[]&gt; {

return this.prisma.user.findMany();

}

async update(id: string, entity: Partial&lt;User&gt;): Promise&lt;User | null&gt; {

return this.prisma.user.update({

where: { id },

data: entity,

});

}

async delete(id: string): Promise&lt;boolean&gt; {

const result = await this.prisma.user.delete({

where: { id },

});

return !!result;

}

}</code></pre>

<h3>Repositório com MongoDB</h3>

<p>Agora implementamos a mesma interface com MongoDB usando o Mongoose. Veja como a interface permanece idêntica, mas a implementação é completamente diferente:</p>

<pre><code class="language-typescript">import mongoose, { Model } from &#039;mongoose&#039;;

import { IRepository } from &#039;./IRepository&#039;;

import { User } from &#039;./User&#039;;

export class MongoUserRepository implements IRepository&lt;User&gt; {

constructor(private model: Model&lt;User&gt;) {}

async create(entity: User): Promise&lt;User&gt; {

const created = new this.model(entity);

return created.save();

}

async findById(id: string): Promise&lt;User | null&gt; {

return this.model.findById(id).exec();

}

async findAll(): Promise&lt;User[]&gt; {

return this.model.find().exec();

}

async update(id: string, entity: Partial&lt;User&gt;): Promise&lt;User | null&gt; {

return this.model.findByIdAndUpdate(id, entity, { new: true }).exec();

}

async delete(id: string): Promise&lt;boolean&gt; {

const result = await this.model.findByIdAndDelete(id).exec();

return !!result;

}

}</code></pre>

<h3>Repositório em Memória para Testes</h3>

<p>Uma das grandes vantagens do padrão fica evidente aqui: podemos criar um repositório que apenas mantém dados em memória, perfeito para testes automatizados:</p>

<pre><code class="language-typescript">import { IRepository } from &#039;./IRepository&#039;;

import { User } from &#039;./User&#039;;

export class InMemoryUserRepository implements IRepository&lt;User&gt; {

private users: Map&lt;string, User&gt; = new Map();

async create(entity: User): Promise&lt;User&gt; {

this.users.set(entity.id, entity);

return entity;

}

async findById(id: string): Promise&lt;User | null&gt; { return this.users.get(id) || null;

}

async findAll(): Promise&lt;User[]&gt; {

return Array.from(this.users.values());

}

async update(id: string, entity: Partial&lt;User&gt;): Promise&lt;User | null&gt; {

const user = this.users.get(id);

if (!user) return null;

const updated = { ...user, ...entity };

this.users.set(id, updated);

return updated;

}

async delete(id: string): Promise&lt;boolean&gt; {

return this.users.delete(id);

}

}</code></pre>

<h2>Integração com a Camada de Negócio</h2>

<h3>Serviço de Usuário</h3>

<p>Agora criamos um serviço que usa o repositório sem conhecer sua implementação. Isso é inversão de dependência: o serviço depende da abstração, não da implementação concreta:</p>

<pre><code class="language-typescript">import { IRepository } from &#039;./IRepository&#039;;

import { User } from &#039;./User&#039;;

export class UserService {

constructor(private userRepository: IRepository&lt;User&gt;) {}

async registerUser(name: string, email: string): Promise&lt;User&gt; {

const newUser: User = {

id: this.generateId(),

name,

email,

createdAt: new Date(),

};

return this.userRepository.create(newUser);

}

async getUserProfile(id: string): Promise&lt;User | null&gt; {

return this.userRepository.findById(id);

}

async updateUserEmail(id: string, newEmail: string): Promise&lt;User | null&gt; {

return this.userRepository.update(id, { email: newEmail });

}

async deleteUser(id: string): Promise&lt;boolean&gt; {

return this.userRepository.delete(id);

}

private generateId(): string {

return user_${Date.now()}_${Math.random().toString(36).substr(2, 9)};

}

}</code></pre>

<h3>Testando o Serviço</h3>

<p>Com a abstração em lugar, escrever testes se torna trivial. Usamos o repositório em memória:</p>

<pre><code class="language-typescript">import { InMemoryUserRepository } from &#039;./InMemoryUserRepository&#039;;

import { UserService } from &#039;./UserService&#039;;

describe(&#039;UserService&#039;, () =&gt; {

let userService: UserService;

let userRepository: InMemoryUserRepository;

beforeEach(() =&gt; {

userRepository = new InMemoryUserRepository();

userService = new UserService(userRepository);

});

it(&#039;should register a new user&#039;, async () =&gt; {

const user = await userService.registerUser(&#039;João Silva&#039;, &#039;joao@example.com&#039;);

expect(user.name).toBe(&#039;João Silva&#039;);

expect(user.email).toBe(&#039;joao@example.com&#039;);

expect(user.id).toBeDefined();

});

it(&#039;should retrieve user by id&#039;, async () =&gt; {

const registered = await userService.registerUser(&#039;Maria&#039;, &#039;maria@example.com&#039;);

const retrieved = await userService.getUserProfile(registered.id);

expect(retrieved).toEqual(registered);

});

it(&#039;should update user email&#039;, async () =&gt; {

const user = await userService.registerUser(&#039;Pedro&#039;, &#039;pedro@example.com&#039;);

const updated = await userService.updateUserEmail(user.id, &#039;newemail@example.com&#039;);

expect(updated?.email).toBe(&#039;newemail@example.com&#039;);

});

it(&#039;should delete user&#039;, async () =&gt; {

const user = await userService.registerUser(&#039;Ana&#039;, &#039;ana@example.com&#039;);

const deleted = await userService.deleteUser(user.id);

expect(deleted).toBe(true);

const found = await userService.getUserProfile(user.id);

expect(found).toBeNull();

});

});</code></pre>

<h2>Injeção de Dependência e Factory Pattern</h2>

<h3>Configurando os Repositórios</h3>

<p>Em uma aplicação real, você não instancia repositórios manualmente em todos os lugares. Use um container de injeção de dependência ou um factory pattern. Aqui está um exemplo simples:</p>

<pre><code class="language-typescript">import { PrismaClient } from &#039;@prisma/client&#039;;

import { IRepository } from &#039;./IRepository&#039;;

import { User } from &#039;./User&#039;;

import { PostgresUserRepository } from &#039;./PostgresUserRepository&#039;;

import { MongoUserRepository } from &#039;./MongoUserRepository&#039;;

import { InMemoryUserRepository } from &#039;./InMemoryUserRepository&#039;;

export class RepositoryFactory {

static createUserRepository(env: &#039;postgres&#039; | &#039;mongo&#039; | &#039;memory&#039;): IRepository&lt;User&gt; {

switch (env) {

case &#039;postgres&#039;:

const prisma = new PrismaClient();

return new PostgresUserRepository(prisma);

case &#039;mongo&#039;:

// Assumindo que a conexão já está feita

const userModel = require(&#039;./models/userModel&#039;).default;

return new MongoUserRepository(userModel);

case &#039;memory&#039;:

return new InMemoryUserRepository();

default:

throw new Error(Repositório desconhecido: ${env});

}

}

}</code></pre>

<h3>Usando o Factory em sua Aplicação</h3>

<pre><code class="language-typescript">const env = process.env.DB_TYPE || &#039;postgres&#039;;

const userRepository = RepositoryFactory.createUserRepository(env as any);

const userService = new UserService(userRepository);

// A aplicação funciona igual, independentemente do banco

const newUser = await userService.registerUser(&#039;João&#039;, &#039;joao@example.com&#039;);</code></pre>

<h2>Extensões Avançadas do Padrão</h2>

<h3>Repositório com Filtros e Paginação</h3>

<p>Em aplicações reais, você precisa de mais do que CRUD básico. Vamos expandir a interface:</p>

<pre><code class="language-typescript">export interface PageOptions {

page: number;

limit: number;

}

export interface PageResult&lt;T&gt; {

data: T[];

total: number;

page: number;

totalPages: number;

}

export interface IRepository&lt;T&gt; {

create(entity: T): Promise&lt;T&gt;;

findById(id: string): Promise&lt;T | null&gt;;

findAll(): Promise&lt;T[]&gt;;

findWithPagination(options: PageOptions): Promise&lt;PageResult&lt;T&gt;&gt;;

findByFilter(filter: Partial&lt;T&gt;, options: PageOptions): Promise&lt;PageResult&lt;T&gt;&gt;;

update(id: string, entity: Partial&lt;T&gt;): Promise&lt;T | null&gt;;

delete(id: string): Promise&lt;boolean&gt;;

}</code></pre>

<p>Agora implementamos em PostgreSQL:</p>

<pre><code class="language-typescript">export class PostgresUserRepository implements IRepository&lt;User&gt; {

constructor(private prisma: PrismaClient) {}

async findWithPagination(options: PageOptions): Promise&lt;PageResult&lt;User&gt;&gt; {

const skip = (options.page - 1) * options.limit;

const [data, total] = await Promise.all([

this.prisma.user.findMany({

skip,

take: options.limit,

orderBy: { createdAt: &#039;desc&#039; },

}),

this.prisma.user.count(),

]);

return {

data,

total,

page: options.page,

totalPages: Math.ceil(total / options.limit),

};

}

async findByFilter(filter: Partial&lt;User&gt;, options: PageOptions): Promise&lt;PageResult&lt;User&gt;&gt; {

const skip = (options.page - 1) * options.limit;

const [data, total] = await Promise.all([

this.prisma.user.findMany({

where: {

...(filter.name &amp;&amp; { name: { contains: filter.name, mode: &#039;insensitive&#039; } }),

...(filter.email &amp;&amp; { email: { contains: filter.email, mode: &#039;insensitive&#039; } }),

},

skip,

take: options.limit,

orderBy: { createdAt: &#039;desc&#039; },

}),

this.prisma.user.count({

where: {

...(filter.name &amp;&amp; { name: { contains: filter.name, mode: &#039;insensitive&#039; } }),

...(filter.email &amp;&amp; { email: { contains: filter.email, mode: &#039;insensitive&#039; } }),

},

}),

]);

return {

data,

total,

page: options.page,

totalPages: Math.ceil(total / options.limit),

};

}

// ... outros métodos

}</code></pre>

<h3>Exemplo de Uso com Filtros</h3>

<pre><code class="language-typescript">const userService = new UserService(userRepository);

// Buscar página 1 com 10 itens

const page1 = await userRepository.findWithPagination({ page: 1, limit: 10 });

// Buscar usuários cujo nome contém &quot;João&quot;

const filtered = await userRepository.findByFilter(

{ name: &#039;João&#039; },

{ page: 1, limit: 5 }

);

console.log(Total de usuários: ${filtered.total});

console.log(Total de páginas: ${filtered.totalPages});

console.log(Usuários na página 1:, filtered.data);</code></pre>

<h2>Conclusão</h2>

<p>O Repository Pattern com TypeScript oferece três benefícios fundamentais que você aprendeu neste artigo: <strong>abstração da camada de dados</strong> que permite trocar implementações sem alterar a lógica de negócio, <strong>testabilidade garantida</strong> pela facilidade de criar repositórios mock em memória, e <strong>manutenibilidade a longo prazo</strong> através de interfaces bem definidas que funcionam como contratos. A tipagem forte do TypeScript potencializa esses benefícios, prevenindo erros em tempo de compilação e oferecendo autocompletar robusto. Use o padrão com inteligência: em aplicações pequenas pode ser over-engineering, mas em sistemas médios a grandes traz organização e flexibilidade que economiza horas de refatoração no futuro.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://www.typescriptlang.org/docs/handbook/2/objects.html" target="_blank" rel="noopener noreferrer">TypeScript Handbook - Interfaces</a></li>

<li><a href="https://www.prisma.io/docs/" target="_blank" rel="noopener noreferrer">Prisma Documentation</a></li>

<li><a href="https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-design" target="_blank" rel="noopener noreferrer">Microsoft: Repository Pattern</a></li>

<li><a href="https://martinfowler.com/eaaCatalog/repository.html" target="_blank" rel="noopener noreferrer">Martin Fowler: Repository</a></li>

<li><a href="https://en.wikipedia.org/wiki/Design_Patterns" target="_blank" rel="noopener noreferrer">Design Patterns: Elements of Reusable Object-Oriented Software - Gang of Four</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em TypeScript

O que Todo Dev Deve Saber sobre Turborepo com TypeScript: Monorepo de Alta Performance
O que Todo Dev Deve Saber sobre Turborepo com TypeScript: Monorepo de Alta Performance

Entendendo Monorepos e o Papel do Turborepo Um monorepo é um repositório únic...

Testes de Integração com TypeScript: Banco Real e Fixtures Tipadas: Do Básico ao Avançado
Testes de Integração com TypeScript: Banco Real e Fixtures Tipadas: Do Básico ao Avançado

O que são Testes de Integração e Por Que Importam Testes de integração valida...

Guia Completo de Recursive Types em TypeScript: Árvores, JSON e Estruturas Profundas
Guia Completo de Recursive Types em TypeScript: Árvores, JSON e Estruturas Profundas

Entendendo Tipos Recursivos em TypeScript Um tipo recursivo é aquele que faz...