DEV Community

Cover image for Stop Massacring the Repository Pattern: A Love Letter to Separation of Concerns
Adam - The Developer
Adam - The Developer

Posted on

Stop Massacring the Repository Pattern: A Love Letter to Separation of Concerns

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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 */ };
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

"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:

  1. Could I swap the database with zero business logic changes?
  2. Does this method only touch data?
  3. Could a junior dev understand what this does in 10 seconds?
  4. 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)