So... I've been reviewing code for years, and I've seen things. Terrible things. Things that would make Uncle Bob weep into his clean code manual. And the most consistent pattern of abuse? The repository pattern itself.
Ironic, isn't it? The pattern designed to bring order to chaos has become the chaos.
The Crime Scene
Let me paint you a picture. You open a NestJS project. Everything looks fine. Clean folder structure. TypeScript configured beautifully. Docker compose file that actually works on the first try (rare, I know).
Then you open UserRepository.ts
and your soul leaves your body:
@Injectable()
export class UserRepository {
constructor(
@InjectRepository(User) private userModel: Repository<User>,
private emailService: EmailService,
private stripeService: StripeService,
private notificationService: NotificationService,
private analyticsService: AnalyticsService,
private cacheService: CacheService,
private loggingService: LoggingService,
private configService: ConfigService,
) {}
async createPremiumUser(
email: string,
password: string,
planType: string
) {
// Validate email format
if (!this.isValidEmail(email)) {
throw new BadRequestException('Invalid email');
}
// Check if email exists
const existingUser = await this.userModel.findOne({ where: { email } });
if (existingUser) {
throw new ConflictException('Email already exists');
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create Stripe customer
const stripeCustomer = await this.stripeService.createCustomer(email);
// Subscribe to plan
await this.stripeService.subscribe(stripeCustomer.id, planType);
// Create user
const user = await this.userModel.create({
email,
password: hashedPassword,
stripeCustomerId: stripeCustomer.id,
plan: planType,
isPremium: true,
});
// Send welcome email
await this.emailService.sendWelcomeEmail(email);
// Track analytics
await this.analyticsService.track('user_created', {
userId: user.id,
plan: planType,
});
// Invalidate cache
await this.cacheService.invalidate('users:*');
// Log everything
this.loggingService.info('Premium user created', { userId: user.id });
return user;
}
private isValidEmail(email: string): boolean {
// 50 lines of regex validation
}
}
This is not a repository. This is a god class wearing a repository costume.
What Even IS a Repository?
Let's get philosophical for a second. A repository is meant to be a collection-like interface to your data store. Think of it like a librarian.
A librarian doesn't:
- Validate whether you're allowed to read the book
- Check if you've paid your library fees
- Send you email reminders
- Analyze your reading habits
- Write book recommendations
A librarian:
- Gets books from shelves
- Puts books back on shelves
- Tells you if a book exists
- That's literally it
Your repository should be the same level of boring.
The Golden Rules:
Rule 1: A Repository Should Be Replaceable
If you can't swap your Postgres repository for a Mongo one, a Redis one, or even an in-memory array without changing business logic, you've failed.
Your repository should be so data-access-focused that switching databases is just a matter of implementing the same interface differently.
interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
findAll(filters?: UserFilters): Promise<User[]>;
}
// Postgresql implementation
class PostgresUserRepository implements IUserRepository {
// Just TypeORM queries here
}
// Mongodb implementation
class MongoUserRepository implements IUserRepository {
// Just Mongoose queries here
}
// In-memory for testing
class InMemoryUserRepository implements IUserRepository {
private users: Map<string, User> = new Map();
// Just array operations here
}
Rule 2: Zero Business Logic
ZERO. Not a little bit. Not "just this one validation". ZERO.
Your repository doesn't know:
- What makes a valid email
- What password requirements are
- What premium features users get
- When to send emails
- What the business rules are
It only knows how to talk to the database.
// ❌ NO
async createUser(userData: CreateUserDto) {
if (userData.age < 18) {
throw new Error('Must be 18+');
}
// ...
}
// ✅ YES
async save(user: User): Promise<User> {
return this.userModel.save(user);
}
Rule 3: One Repository Per Aggregate Root
This is Domain-Driven Design 101. If you have a User
aggregate, you have a UserRepository
. You don't have UserEmailRepository
, UserPasswordRepository
, UserPreferencesRepository
.
// ❌ Too granular
class UserRepository { }
class UserEmailRepository { }
class UserPasswordRepository { }
class UserProfileRepository { }
// ✅ One aggregate, one repository
class UserRepository {
// Handles all User data access
}
The Correct Architecture
Here's how to actually structure this:
Layer 1: Domain Entities
// domain/user.entity.ts
export class User {
id: string;
email: string;
passwordHash: string;
isPremium: boolean;
createdAt: Date;
// Domain methods (if you're feeling fancy)
upgradeToPremium(): void {
this.isPremium = true;
}
isEligibleForPromotion(): boolean {
// Business logic lives here or in domain services
return !this.isPremium && this.createdAt < new Date('2024-01-01');
}
}
Layer 2: Repository Interface & Implementation
// domain/repositories/user-repository.interface.ts
export interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
exists(email: string): Promise<boolean>;
}
// infrastructure/repositories/typeorm-user.repository.ts
@Injectable()
export class TypeOrmUserRepository implements IUserRepository {
constructor(
@InjectRepository(UserEntity)
private readonly userModel: Repository<UserEntity>,
) {}
async findById(id: string): Promise<User | null> {
const entity = await this.userModel.findOne({ where: { id } });
return entity ? this.toDomain(entity) : null;
}
async save(user: User): Promise<User> {
const entity = this.toEntity(user);
const saved = await this.userModel.save(entity);
return this.toDomain(saved);
}
// Pure data mapping, nothing else
private toDomain(entity: UserEntity): User {
return new User(/* map fields */);
}
private toEntity(user: User): UserEntity {
return { /* map fields */ };
}
}
Notice what's missing? Everything interesting. This is glorified CRUD. That's the point.
Layer 3: Application Services (Use Cases)
This is where the magic happens. Where business logic lives. Where you orchestrate everything.
// application/use-cases/create-premium-user.use-case.ts
@Injectable()
export class CreatePremiumUserUseCase {
constructor(
private readonly userRepository: IUserRepository,
private readonly emailService: IEmailService,
private readonly paymentService: IPaymentService,
private readonly eventBus: IEventBus,
) {}
async execute(command: CreatePremiumUserCommand): Promise<User> {
// 1. Validate (or use a validator service)
this.validateEmail(command.email);
// 2. Check business rules
if (await this.userRepository.exists(command.email)) {
throw new UserAlreadyExistsException();
}
// 3. Create domain entity
const user = User.create({
email: command.email,
passwordHash: await this.hashPassword(command.password),
isPremium: true,
});
// 4. Handle payment
const paymentResult = await this.paymentService.createSubscription({
email: user.email,
plan: command.planType,
});
user.setPaymentId(paymentResult.id);
// 5. Persist
const savedUser = await this.userRepository.save(user);
// 6. Side effects
await this.emailService.sendWelcomeEmail(savedUser.email);
await this.eventBus.publish(new UserCreatedEvent(savedUser));
return savedUser;
}
private validateEmail(email: string): void {
// Validation logic
}
private async hashPassword(password: string): Promise<string> {
// Hashing logic
}
}
Now THAT'S beautiful. Everything has its place:
- Repository: Dumb data access
- Use Case: Smart business orchestration
- Domain Entity: Core business rules
- Services: Specialized concerns (email, payment, etc.)
Layer 4: Controllers (API Layer)
@Controller('users')
export class UsersController {
constructor(
private readonly createPremiumUser: CreatePremiumUserUseCase,
) {}
@Post('premium')
async createPremium(@Body() dto: CreatePremiumUserDto) {
const command = new CreatePremiumUserCommand(
dto.email,
dto.password,
dto.planType,
);
return this.createPremiumUser.execute(command);
}
}
The DI Setup That Doesn't Suck
Here's how to wire this up in NestJS without wanting to uninstall Node.js:
// infrastructure/infrastructure.module.ts
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
providers: [
{
provide: 'IUserRepository',
useClass: TypeOrmUserRepository,
},
],
exports: ['IUserRepository'],
})
export class InfrastructureModule {}
// application/application.module.ts
@Module({
imports: [InfrastructureModule],
providers: [
CreatePremiumUserUseCase,
// Other use cases
],
exports: [CreatePremiumUserUseCase],
})
export class ApplicationModule {}
// In your use case
@Injectable()
export class CreatePremiumUserUseCase {
constructor(
@Inject('IUserRepository')
private readonly userRepository: IUserRepository,
) {}
}
Clean. Testable. No circular dependency hell.
Testing Becomes Trivial
Remember that in-memory repository we mentioned? Here's why it matters:
describe('CreatePremiumUserUseCase', () => {
let useCase: CreatePremiumUserUseCase;
let userRepository: InMemoryUserRepository;
let emailService: MockEmailService;
let paymentService: MockPaymentService;
beforeEach(() => {
userRepository = new InMemoryUserRepository();
emailService = new MockEmailService();
paymentService = new MockPaymentService();
useCase = new CreatePremiumUserUseCase(
userRepository,
emailService,
paymentService,
new MockEventBus(),
);
});
it('should create premium user', async () => {
const command = new CreatePremiumUserCommand(
'test@example.com',
'password123',
'premium',
);
const user = await useCase.execute(command);
expect(user.isPremium).toBe(true);
expect(emailService.sentEmails).toHaveLength(1);
expect(userRepository.users.size).toBe(1);
});
});
No database needed. No mocking libraries. No TestBed configuration from hell. Just pure, beautiful unit tests.
Common Objections (And Why They're Wrong)
"But this is overengineered for my small project!"
Maybe. But you know what's worse? A small project that becomes a medium project that becomes a large project with repositories that have 47 dependencies and 2000 lines of code.
Start clean. Stay clean.
"We need to query across multiple aggregates!"
Then create a Query Service or use CQRS. Don't pollute your repositories.
@Injectable()
export class UserAnalyticsQueryService {
constructor(
@InjectRepository(User) private userModel: Repository<User>,
@InjectRepository(Order) private orderModel: Repository<Order>,
) {}
async getUsersWithHighSpending(): Promise<UserAnalyticsDto[]> {
// Complex joins and queries here
// This is read-only, so it's fine
}
}
"My boss wants this done by Friday!"
Your boss also wants maintainable code that doesn't require a full rewrite every 6 months. This IS faster in the long run.
The Litmus Test
Before you commit that repository code, ask yourself:
- Could I swap the database with zero business logic changes?
- Does this method only touch data?
- Could a junior dev understand what this does in 10 seconds?
- Would this work with an in-memory array?
If you answered "no" to any of these, you're doing it wrong.
Conclusion
The repository pattern is not your dumping ground for "I don't know where else to put this" code. It's a sacred boundary between your business logic and your data persistence.
Treat it with respect. Keep it boring. Keep it dumb.
Your future self (and your teammates) will thank you.
Now go forth and write repositories so simple they make you question why you even need them. That's when you know you've done it right.
Have you committed repository crimes? Confess in the comments. This is a judgment-free zone. (It's not, but I'll pretend.)
Top comments (0)