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 bricklayer.
A bricklayer doesn’t:
- Approve blueprints
- Decide which building to construct
- Handle building permits
- Manage budgets or timelines
- Write architectural plans
A bricklayer:
- Lays bricks
- Picks them back up if they’re in the wrong place
- Tells you if you’ve run out of bricks
- That’s it. That’s their job.
The architect (your service or domain logic) decides what to build.
The bricklayer (your repository) just puts data where it belongs.
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 (4)
If it is not a repository that looks like that it is a service. The cause of this problem is that people treat keep your controllers thin as a dogma, and just copy-paste the code somewhere else.
As a side note, the librarian analogy falls a little flat. The do validate if you are allowed to read books. The libraries I go to have a books per user limit. And they do check if you payed your fees. I think if librarians read what you think they do, for this post, they could be offended.
A better analogy might be a bricklayer, they just need to know what stones they need to use, where they need to leave openings and at what height they need to stop. They don't care about doors, windows, a roof, isolation and so on.
I agree with the librarian part, think id consider changing to use the bricklayer instead.
I follow the same pattern with my repositories only performing glorified CRUD ops. Service layer handles business needs and events handle misc stuff like email sending, notifications, etc. Great article. And yes, Controllers should definitely be thin !!! Sometimes, we have more tools that make use of the code than just an API and we want any changes to reflect there as well. Think of a REPL or console app that needs to have access to all the services and biz logic to maintain parity w/ the api. Very useful there
The reason I mentioned it is a dogma, is because there are people that think the thinness of the controllers is the goal.
The thinness of controllers is caused by multiple uses of the code, as you mention. Or creating abstractions because you want to create distance between the caller and the execution, for example using a database connection abstraction to make it possible to switch by using a DSN string.
And there are probably a few more reasons, but line count is not one of them.
If a controller method's code is not reused or abstracted, there is no need to move the code out of the controller.
Let each individual case follow it's own path, instead of trying to wiggle them into a design pattern or architecture.