Appearance
Repository Pattern in NestJS
Implementing domain-focused data access in NestJS applications
Implements: Repository Pattern | Dependency Inversion
Overview
Repositories in NestJS provide a clean abstraction between your domain logic and data persistence. They operate on domain entities, hide storage implementation details, and enable easy testing through dependency injection.
Repository Architecture
Interface Definition (Port)
Repository interfaces are defined in the domain or use case layer:
typescript
// domain/interfaces/user-repository.interface.ts
export interface UserRepositoryInterface {
save(user: User): Promise<User>;
findById(id: UserId): Promise<User | null>;
findByEmail(email: Email): Promise<User | null>;
findActiveUsers(): Promise<User[]>;
delete(id: UserId): Promise<void>;
exists(email: Email): Promise<boolean>;
}Implementation (Adapter)
Concrete implementations live in the infrastructure layer:
typescript
// infrastructure/repositories/prisma-user.repository.ts
@Injectable()
export class PrismaUserRepository implements UserRepositoryInterface {
constructor(private readonly prisma: PrismaService) {}
async save(user: User): Promise<User> {
const data = this.toPersistence(user);
const saved = await this.prisma.user.upsert({
where: { id: data.id },
create: data,
update: data,
});
return this.toDomain(saved);
}
async findById(id: UserId): Promise<User | null> {
const record = await this.prisma.user.findUnique({
where: { id: id.toString() },
});
return record ? this.toDomain(record) : null;
}
async findByEmail(email: Email): Promise<User | null> {
const record = await this.prisma.user.findUnique({
where: { email: email.getValue() },
});
return record ? this.toDomain(record) : null;
}
private toPersistence(user: User): any {
return {
id: user.getId().toString(),
email: user.getEmail().getValue(),
name: user.getName(),
status: user.getStatus(),
created_at: user.getCreatedAt(),
updated_at: user.getUpdatedAt(),
};
}
private toDomain(record: any): User {
return User.reconstitute(
new UserId(record.id),
new Email(record.email),
record.name,
record.status,
record.created_at,
record.updated_at,
);
}
}Data Mapping Strategies
Domain to Persistence Mapping
Convert rich domain objects to flat database records:
typescript
@Injectable()
export class OrderRepository implements OrderRepositoryInterface {
private toPersistence(order: Order): any {
// Flatten nested value objects
const { amount, currency } = order.getTotal();
return {
id: order.getId().toString(),
customer_id: order.getCustomerId().toString(),
status: order.getStatus(),
total_amount: amount,
total_currency: currency,
// Handle collections
items: order.getItems().map(item => ({
product_id: item.getProductId(),
quantity: item.getQuantity(),
price: item.getPrice().getAmount(),
currency: item.getPrice().getCurrency(),
})),
// Serialize complex value objects
shipping_address: JSON.stringify(order.getShippingAddress()),
metadata: order.getMetadata(),
};
}
}Persistence to Domain Mapping
Reconstruct domain entities from database records:
typescript
private toDomain(record: any): Order {
// Reconstruct value objects
const orderId = new OrderId(record.id);
const customerId = new CustomerId(record.customer_id);
const total = new Money(record.total_amount, record.total_currency);
// Map collections
const items = record.items.map((item: any) =>
OrderItem.reconstitute(
item.product_id,
item.quantity,
new Money(item.price, item.currency),
)
);
// Deserialize complex objects
const shippingAddress = Address.fromJSON(
JSON.parse(record.shipping_address)
);
return Order.reconstitute(
orderId,
customerId,
items,
total,
shippingAddress,
record.status,
record.metadata,
);
}Aggregate Repositories
Repositories should operate on aggregate roots, managing the entire aggregate:
typescript
// domain/interfaces/organization-repository.interface.ts
export interface OrganizationRepositoryInterface {
save(organization: Organization): Promise<void>;
findById(id: OrganizationId): Promise<Organization | null>;
findBySlug(slug: string): Promise<Organization | null>;
}
// infrastructure/repositories/organization.repository.ts
@Injectable()
export class PrismaOrganizationRepository implements OrganizationRepositoryInterface {
constructor(private readonly prisma: PrismaService) {}
async save(organization: Organization): Promise<void> {
const data = this.toPersistence(organization);
// Save aggregate root and all related entities in a transaction
await this.prisma.$transaction(async (tx) => {
// Save organization
await tx.organization.upsert({
where: { id: data.id },
create: data.organization,
update: data.organization,
});
// Delete removed members
await tx.member.deleteMany({
where: {
organization_id: data.id,
id: { notIn: data.memberIds },
},
});
// Upsert current members
for (const member of data.members) {
await tx.member.upsert({
where: { id: member.id },
create: member,
update: member,
});
}
// Handle other aggregate parts...
});
}
async findById(id: OrganizationId): Promise<Organization | null> {
const record = await this.prisma.organization.findUnique({
where: { id: id.toString() },
include: {
members: true,
settings: true,
// Include all aggregate parts
},
});
if (!record) {
return null;
}
return this.toDomain(record);
}
private toDomain(record: any): Organization {
const members = record.members.map((m: any) =>
Member.reconstitute(
new MemberId(m.id),
new UserId(m.user_id),
m.role,
m.joined_at,
)
);
const settings = OrganizationSettings.reconstitute(
record.settings.timezone,
record.settings.language,
record.settings.features,
);
return Organization.reconstitute(
new OrganizationId(record.id),
record.name,
record.slug,
members,
settings,
record.created_at,
);
}
}Query Optimization
Lazy Loading vs Eager Loading
typescript
export class ProductRepository implements ProductRepositoryInterface {
// Simple find - minimal data
async findById(id: ProductId): Promise<Product | null> {
const record = await this.prisma.product.findUnique({
where: { id: id.toString() },
});
return record ? this.toDomain(record) : null;
}
// Find with relations - eager load when needed
async findByIdWithReviews(id: ProductId): Promise<ProductWithReviews | null> {
const record = await this.prisma.product.findUnique({
where: { id: id.toString() },
include: {
reviews: {
orderBy: { created_at: 'desc' },
take: 10,
},
category: true,
},
});
return record ? this.toDomainWithReviews(record) : null;
}
}Specification Pattern
Implement complex queries with specifications:
typescript
// domain/specifications/active-premium-users.specification.ts
export class ActivePremiumUsersSpecification {
constructor(
private readonly minPurchases: number = 5,
private readonly since: Date = subMonths(new Date(), 3),
) {}
toQuery() {
return {
status: 'ACTIVE',
subscription_tier: 'PREMIUM',
purchases: {
count: { gte: this.minPurchases },
},
last_login: { gte: this.since },
};
}
}
// Repository implementation
export class UserRepository {
async findBySpecification(spec: Specification): Promise<User[]> {
const records = await this.prisma.user.findMany({
where: spec.toQuery(),
});
return records.map(this.toDomain);
}
}
// Usage in use case
const activeUsers = await this.userRepository.findBySpecification(
new ActivePremiumUsersSpecification(10)
);Dependency Injection
Module Registration
Register repositories with injection tokens:
typescript
// infrastructure/users.module.ts
@Module({
providers: [
{
provide: 'USER_REPOSITORY',
useClass: PrismaUserRepository,
},
// Alternative: Use factory for conditional implementation
{
provide: 'ORDER_REPOSITORY',
useFactory: (config: ConfigService, prisma: PrismaService) => {
const dbType = config.get('database.type');
switch (dbType) {
case 'postgres':
return new PrismaOrderRepository(prisma);
case 'mongodb':
return new MongoOrderRepository(config);
case 'memory':
return new InMemoryOrderRepository();
default:
throw new Error(`Unsupported database type: ${dbType}`);
}
},
inject: [ConfigService, PrismaService],
},
],
exports: ['USER_REPOSITORY', 'ORDER_REPOSITORY'],
})
export class UsersModule {}Using in Use Cases
Inject repositories using tokens:
typescript
@Injectable()
export class RegisterUserUseCase {
constructor(
@Inject('USER_REPOSITORY')
private readonly userRepository: UserRepositoryInterface,
@Inject('EMAIL_SERVICE')
private readonly emailService: EmailServiceInterface,
) {}
async execute(request: RegisterUserRequest): Promise<User> {
// Check if user exists
const existingUser = await this.userRepository.findByEmail(
new Email(request.email)
);
if (existingUser) {
throw new UserAlreadyExistsError();
}
// Create and save new user
const user = User.create(
new Email(request.email),
request.name,
request.password,
);
await this.userRepository.save(user);
await this.emailService.sendWelcomeEmail(user);
return user;
}
}Testing Repositories
In-Memory Implementation
Create test doubles for unit testing:
typescript
// test/repositories/in-memory-user.repository.ts
export class InMemoryUserRepository implements UserRepositoryInterface {
private users = new Map<string, User>();
async save(user: User): Promise<User> {
this.users.set(user.getId().toString(), user);
return user;
}
async findById(id: UserId): Promise<User | null> {
return this.users.get(id.toString()) || null;
}
async findByEmail(email: Email): Promise<User | null> {
for (const user of this.users.values()) {
if (user.getEmail().equals(email)) {
return user;
}
}
return null;
}
async findActiveUsers(): Promise<User[]> {
return Array.from(this.users.values()).filter(
user => user.getStatus() === UserStatus.ACTIVE
);
}
async delete(id: UserId): Promise<void> {
this.users.delete(id.toString());
}
async exists(email: Email): Promise<boolean> {
return (await this.findByEmail(email)) !== null;
}
// Test helper methods
clear(): void {
this.users.clear();
}
getAll(): User[] {
return Array.from(this.users.values());
}
}Integration Testing
Test real repository implementations:
typescript
describe('PrismaUserRepository', () => {
let repository: PrismaUserRepository;
let prisma: PrismaService;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [PrismaModule],
providers: [PrismaUserRepository],
}).compile();
repository = module.get<PrismaUserRepository>(PrismaUserRepository);
prisma = module.get<PrismaService>(PrismaService);
});
beforeEach(async () => {
await prisma.user.deleteMany();
});
describe('save', () => {
it('should persist a new user', async () => {
const user = User.create(
new Email('test@example.com'),
'Test User',
'password123',
);
const saved = await repository.save(user);
expect(saved.getId()).toEqual(user.getId());
expect(saved.getEmail()).toEqual(user.getEmail());
const found = await repository.findById(user.getId());
expect(found).toBeDefined();
});
it('should update an existing user', async () => {
const user = User.create(
new Email('test@example.com'),
'Test User',
'password123',
);
await repository.save(user);
user.changeName('Updated Name');
await repository.save(user);
const found = await repository.findById(user.getId());
expect(found?.getName()).toBe('Updated Name');
});
});
});Advanced Patterns
Unit of Work
Manage transactions across multiple repositories:
typescript
@Injectable()
export class PrismaUnitOfWork {
constructor(private readonly prisma: PrismaService) {}
async execute<T>(
work: (tx: Prisma.TransactionClient) => Promise<T>
): Promise<T> {
return this.prisma.$transaction(work);
}
}
// Usage in use case
@Injectable()
export class TransferMoneyUseCase {
constructor(
private readonly unitOfWork: PrismaUnitOfWork,
@Inject('ACCOUNT_REPOSITORY')
private readonly accountRepository: AccountRepositoryInterface,
) {}
async execute(request: TransferMoneyRequest): Promise<void> {
await this.unitOfWork.execute(async (tx) => {
const fromAccount = await this.accountRepository.findById(
request.fromAccountId,
tx, // Pass transaction context
);
const toAccount = await this.accountRepository.findById(
request.toAccountId,
tx,
);
fromAccount.withdraw(request.amount);
toAccount.deposit(request.amount);
await this.accountRepository.save(fromAccount, tx);
await this.accountRepository.save(toAccount, tx);
});
}
}Caching Layer
Add caching without changing the interface:
typescript
@Injectable()
export class CachedUserRepository implements UserRepositoryInterface {
constructor(
private readonly repository: PrismaUserRepository,
@Inject(CACHE_MANAGER)
private readonly cache: Cache,
) {}
async findById(id: UserId): Promise<User | null> {
const cacheKey = `user:${id.toString()}`;
// Check cache
const cached = await this.cache.get<User>(cacheKey);
if (cached) {
return cached;
}
// Fetch from database
const user = await this.repository.findById(id);
if (user) {
await this.cache.set(cacheKey, user, 300); // 5 minutes
}
return user;
}
async save(user: User): Promise<User> {
const saved = await this.repository.save(user);
// Invalidate cache
const cacheKey = `user:${user.getId().toString()}`;
await this.cache.del(cacheKey);
return saved;
}
}Best Practices
DO ✅
- Return domain entities from repository methods
- Hide persistence details within the repository
- Use transactions for aggregate consistency
- Map between domains and persistence models explicitly
- Test with both in-memory and real implementations
DON'T ❌
- Expose database queries in repository interfaces
- Return database models from repository methods
- Create generic repositories for everything
- Mix read and write operations indiscriminately
- Leak ORM-specific types through the interface
Next Steps
- Learn testing strategies in Unit Testing
- Explore Integration Testing for repositories
- Understand the broader context in Clean Architecture in NestJS
Related Concepts
- Repository Pattern - Theoretical foundation
- Dependency Inversion - Ports and adapters
- Clean Architecture - Architectural context