46.6 Repository Pattern with TypeScript and an ORM
Right, let’s talk about the Repository Pattern. You’ve probably heard the term thrown around, often accompanied by vague hand-waving about “abstraction” and “separation of concerns.” Let’s cut through the noise. At its core, the Repository Pattern is just a fancy way of saying: “I’m going to put all my data access code in one place so the rest of my application can stop worrying about it.” It’s a lie-to-children abstraction over your data store, and when done right in TypeScript, it’s a thing of beauty.
Think of it this way: your business logic shouldn’t care if your users are stored in PostgreSQL, a JSON file, or the memory of a distracted intern named Kevin. It just needs to ask for a user by ID and get a User object back. The Repository is the brilliant, overworked intermediary that handles the messy conversation with the database (or Kevin) for you.
Why Bother? The Case for Abstraction
You might be thinking, “My ORM [like TypeORM or Prisma] is already an abstraction layer!” And you’re right, it is. But it’s the wrong abstraction for your domain. Your ORM knows about database rows, connections, and migrations. Your domain should know about Users, Orders, and Invoices. A Repository wraps the ORM-specific nonsense and presents a clean, domain-centric API.
This buys you three huge wins:
- Testability: You can mock the absolute daylights out of your repository. Testing your business logic becomes a matter of “when I ask the mock repository for user ID 5, return this specific
Userobject.” No databases, no ORM setup, no Kevin. Just clean, fast, deterministic unit tests. - Flexibility: Need to switch from MongoDB to SQL next year? Good luck refactoring ORM calls sprinkled across 300 service files. With the Repository pattern, you change the implementation in one place. The rest of your application keeps calling
userRepository.findById(id)and is none the wiser. - Simplicity: Your services get dumber, and dumb code is good code. They become orchestrators of domain objects and repositories, not masters of SQL queries.
The Blueprint: Defining the Interface
This is the most important part. The power of the pattern comes from coding to an interface, not an implementation. We define what we can do with our users, completely ignoring how it’s done.
// domain/interfaces/UserRepository.ts
import { User } from '../entities/User';
export interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
findAll(): Promise<User[]>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}
Look at that. It’s just a contract. It promises nothing about technology. This interface is what your services will depend on, and it’s what you’ll mock in your tests. The implementation could use TypeORM, Prisma, an array in memory, or a fax machine. The domain doesn’t care.
The Grunt Work: Implementing with TypeORM
Now, let’s make the thing that actually talks to the database. This is where we fulfill the contract and get our hands dirty with ORM specifics.
// infrastructure/persistence/TypeOrmUserRepository.ts
import { Repository } from 'typeorm';
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/interfaces/UserRepository';
import { AppDataSource } from './data-source'; // Your TypeORM setup
export class TypeOrmUserRepository implements UserRepository {
private repository: Repository<User>;
constructor() {
this.repository = AppDataSource.getRepository(User);
}
async findById(id: string): Promise<User | null> {
// See? We're using the ORM, but the method name is from OUR interface.
return this.repository.findOne({ where: { id } });
}
async findByEmail(email: string): Promise<User | null> {
return this.repository.findOne({ where: { email } });
}
async findAll(): Promise<User[]> {
return this.repository.find();
}
async save(user: User): Promise<void> {
await this.repository.save(user);
}
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
}
Notice how the class implements UserRepository? TypeScript will now scream at us if we don’t fulfill the entire contract. This is your first line of defense against mistakes.
The Magic Trick: Dependency Injection
This is where it all comes together. You don’t want your services tightly coupled to TypeOrmUserRepository. That would defeat the whole purpose. Instead, you depend on the interface and let the outside world decide which implementation to inject.
// application/services/UserService.ts
import { User } from '../../domain/entities/User';
import { UserRepository } from '../../domain/interfaces/UserRepository';
export class UserService {
// The service only knows about the interface. Blissful ignorance.
constructor(private readonly userRepository: UserRepository) {}
async getUserProfile(userId: string): Promise<User> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new Error('User not found');
}
return user;
}
async registerUser(email: string, name: string): Promise<void> {
const existingUser = await this.userRepository.findByEmail(email);
if (existingUser) {
throw new Error('User already exists');
}
const newUser = new User(); // Assume your Entity has a constructor
newUser.email = email;
newUser.name = name;
await this.userRepository.save(newUser);
}
}
Now, when you create a UserService, you inject the implementation. In your main app setup, you’d do:
// composition root (e.g., app.ts, server.ts)
const userRepository = new TypeOrmUserRepository();
const userService = new UserService(userRepository); // Inject the concrete implementation
And crucially, in your tests:
// test/UserService.spec.ts
const mockUserRepository: UserRepository = {
findById: jest.fn().mockResolvedValue({ id: '123', email: 'test@test.com' }),
findByEmail: jest.fn().mockResolvedValue(null),
// ... mock all other methods
};
const userService = new UserService(mockUserRepository);
// Test your heart out without a database in sight.
Common Pitfalls and The Gotchas
- The Leaky Abstraction: The biggest danger is letting ORM concepts bleed into your domain. Your repository methods should return domain objects, not ORM query builders. If you feel the urge to add a
getQueryBuilder()method to your interface, take a walk and reconsider your life choices. You’re on the path to ruining the abstraction. - Over-Fetching: Your
findAll()method might work for an admin panel but will murder your performance if called for a large dataset. The solution isn’t to abandon the pattern, but to extend your interface thoughtfully. Maybe add afindWithPagination(limit: number, offset: number)method. Your interface evolves with your domain’s needs. - The Generic Trap: You’ll see tutorials offering a “generic repository” (
GenericRepository<T>). It looks seductive. Don’t. It’s a trap. It just moves the ORM dependency from a specific repository to a generic one, solving nothing. Your domain needs specific, intention-revealing methods likefindByEmail, not just a genericfindOne({ where: { email } }). Write the interface that your application actually needs.