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 • Edited 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 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
}
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 (4)

Collapse
 
xwero profile image
david duymelinck • Edited

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.

Collapse
 
adamthedeveloper profile image
Adam - The Developer • Edited

I agree with the librarian part, think id consider changing to use the bricklayer instead.

Collapse
 
microbian-systems profile image
Troy

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

Collapse
 
xwero profile image
david duymelinck

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.