Taming repositories: Thin Repository, Rich Service
This is based on my experience managing various codebases across startups and freelance projects, where I kept running into the same tension between the data access layer and business logic. These are pragmatic patterns I discovered through trial and error in projects where I had decision-making authority.
Thin Repository, Rich Service
The core idea behind Martin Fowler's repository pattern is treating the data access layer like a collection. Hide the low-level details — pagination, sorting, joins — and interact with data through a simple interface, as if you were working with an in-memory collection of objects. That's how I understand it, at least.
The real question is how to implement it. My approach is "Thin Repository, Rich Service": keep repositories as simple, generic data access interfaces, and put all business-concept-related logic in the service layer. I believe this reduces code duplication and makes the codebase more resilient to change.
What you typically see in the wild...
(1) Queries living directly in the service layer
// No data access layer separation — querying directly in the service
class UserService {
async getActiveUser(email: string) {
return this.prisma.user.findFirst({
where: {
email,
status: 'ACTIVE',
lastLoginAt: {
gt: dayjs().subtract(30, 'day').toDate()
}
}
})
}
}
The problems:
- The same query gets copy-pasted across multiple places (DRY violation)
- When the DB schema changes, you have to update multiple locations (and you will miss one)
- Hard to test data access logic in isolation (mocking gets painful — though let's be honest, startups rarely write tests anyway..)
(2) A repository exists, but business logic has leaked in
class UserRepository {
async findActiveUsersByGroup(groupId: string) {
const thirtyDaysAgo = dayjs().subtract(30, 'day').toDate();
const users = await this.prisma.user.findMany({
where: {
groupId,
status: 'ACTIVE',
deletedAt: null,
lastLoginAt: {
gt: thirtyDaysAgo
}
}
});
return users;
}
}
The problems:
- The business concept of "active user" has leaked into the data access layer (separation of concerns violation)
- Need a different view of the same data? Add another method (repository obesity)
- When business requirements change, you have to modify repository code (e.g., what happens when "active" changes from 30 days to 90 days?)
- Methods like
findActiveUsers,findPremiumCustomers,findUnverifiedAccountsproliferate — six months later, nobody remembers what methods exist - When a bulk change is needed (especially with TypeORM query builders), something always gets missed
What I propose instead...
(1) Separate the data access layer cleanly
class UserRepository {
async findOne(options: FindOneOptions<User>): Promise<User> {
return this.prisma.user.findFirst(options);
}
async findMany(options: FindManyOptions<User>): Promise<User[]> {
return this.prisma.user.findMany(options);
}
}
This gives you:
- The repository only deals with data access concerns (clean separation)
- Business logic changes don't ripple into the data access layer (smaller blast radius)
- Different domains can have their own validation logic (extensibility)
- Testing becomes easier (simple mocking)
(2) Business logic lives in the service layer
class UserService {
async getActiveUsers(groupId: string): Promise<User[]> {
// Business logic is defined in the service,
// but filtering happens at the query level
const activeUserQuery = {
groupId,
status: 'ACTIVE',
lastLoginAtGt: dayjs().subtract(30, 'day').toDate()
};
return this.userRepository.findMany(activeUserQuery);
}
}
This gives you:
- Business logic (the definition of "active user") stays in the service layer (one place to change)
- Actual filtering happens at the DB level for performance (better than loading everything into memory)
- The repository stays pure (it just runs queries, oblivious to business concepts)
(3) Performance-critical cases are the exception
class UserRepository {
// Performance-critical special cases can live in the repository
async getTotalSpendingByGroupForActiveUsers(groupId: string) {
// Complex query aggregating total spending for active users by group
return this.prisma.transaction.groupBy({
by: ['userId'],
where: {
user: {
groupId,
status: 'ACTIVE',
lastLoginAt: {
gt: dayjs().subtract(30, 'day').toDate()
}
},
createdAt: {
gt: dayjs().subtract(6, 'month').toDate()
}
},
_sum: {
amount: true
},
orderBy: {
_sum: {
amount: 'desc'
}
}
});
}
}
My guidelines for when to break the rule
In my experience, specialized queries belong directly in the repository only when:
- The performance gain is clear and measurable (e.g., multiple joins and aggregations in a single query)
- The query is too complex for generic methods (multi-join GROUP BY statistics queries — usually admin features)
- You can clearly explain to your team why you did it this way (convincingly enough for a PR review)
- With the thin repository approach: update
getActiveUserQuery()inUserService. Done. - With business logic in the repository: hunt down
findActiveUsers(),findActiveUsersByGroup(),getActiveUserStats(),countActiveUsersInCampaign()— eight methods in total. And of course, one got missed, causing a data inconsistency.
findSomethingActiveUser() method. The result: multiple versions of the same logic, and consistency goes out the window.
Conclusion
"Thin Repository, Rich Service" is a pragmatic extension of the traditional repository pattern. Define complex business concepts in the service layer; let the repository handle pure data access.
The key is minimizing repository methods and maintaining only generic interfaces, so that when business logic changes, you only need to modify the service layer. This effectively prevents repository bloat and code duplication. Keep repositories thin by default, and only allow exceptions with clear justification. That's the pragmatic approach I've arrived at through real projects.
Before writing this, I searched for other developers who'd been thinking along the same lines. There weren't as many as I expected, but I found a few with similar concerns. Matías Navarro-Carter's The Repository Pattern Done Right reached the same conclusion in a PHP/Doctrine context — treat repositories as immutable collections and eliminate all methods tied to specific business concepts.
2026. 3. addendum — After publishing this, I got questions about this pattern in several interviews. In one, the interviewer had my post open on their screen. They'd clearly read it closely. It was embarrassing, but the conversation was fun. Their team had gone AI-friendly — using ORM queries directly in the service layer. "The more abstraction layers you have, the more context you need to feed the AI. It's a waste of tokens. Instead of spending time conforming to Repository interfaces, it's faster to just use Prisma directly in the service and let AI handle it." When I pushed back about unit testing being harder, they said: "Mocking repositories for unit tests — the mock can end up diverging from real behavior anyway. E2E tests are what guarantee actual behavior." They focused entirely on making full E2E tests work well, since they believed those mattered more than unit tests. I couldn't disagree. And now, less than a year later, that interviewer was right — the boilerplate I built around this pattern is creating friction in my AI-assisted workflow. I've had to step back from this policy in every project I build.