Welcome back to The Everyday Backend Engineer: Practical Design Patterns. In our last post, we shielded our system from unstable third-party SDKs using the Adapter Pattern. Today, we close out Category 2: Structural Patterns with a design pattern that keeps your service layer immaculate: The Repository Pattern.
Let’s dismantle the bad habit of leaking database queries directly into core business services.
🔴 The Problem: Database Bleeding (The SQL Leak)
Imagine you are developing a user analytics dashboard feature. Your application uses an ORM (like Prisma or Mongoose) or raw SQL via a database driver.
A standard instinct when implementing a business requirement is to construct and run your database queries right inside the business logic service:
// ❌ Bad Practice: Raw database syntax mixed into business use-cases
const db = require('../config/database');
class AnalyticsService {
async getActivePremiumUsers() {
console.log("Running business computations...");
// The codebase smell: Leaking raw database implementation details
const sql = `SELECT * FROM users WHERE status = 'active' AND tier = 'premium' AND last_login >= NOW() - INTERVAL '30 days'`;
const result = await db.query(sql);
return result.rows;
}
}
module.exports = AnalyticsService;
Why is this structural mix a vulnerability?
Your core business rules layer is now directly coupled with your persistence layer engine.
- Zero Portability: If your company shifts from PostgreSQL to MongoDB or DynamoDB next month, you have to surgically extract and rewrite the internal query code across dozens of service files.
- Duplicated Code: If an authorization controller, a notification worker, and a checkout task all need to locate active users, they will often duplicate these raw queries, leading to maintenance debt.
🟢 The Solution: A Dedicated Data Access Library
The business logic layer should never know how data is stored, cached, or retrieved. It should simply request data via a clean, mockable API.
The Repository Pattern abstracts the data tier completely behind a dedicated storage collection class. The service layer talks exclusively to the Repository using simple method declarations (.findActivePremiumUsers()), completely oblivious to whether the data comes from PostgreSQL, an in-memory Redis cache, or a static mock file.
📚 The Real-World Analogy: "The Library"
Think of visiting a Public Library to get a science book. You don't walk into the storage building, climb ladders, and rummage through cardboard boxes yourself. Instead, you walk up to the Librarian (The Repository) and say: "Give me the latest chemistry book." How that book is categorized or where it is shelved is the librarian's responsibility. You just receive the asset cleanly at the desk.
💻 The Clean Node.js Implementation
Let’s isolate our persistence layers completely by setting up a dedicated data layer.
First, we create our standalone Repository file that owns the raw database interaction exclusively:
// repositories/userRepository.js
const db = require('../config/database'); // Singleton database connection instance
class UserRepository {
// 🎯 Isolate raw database querying syntax completely inside this class
async findActivePremiumUsers() {
const sql = `
SELECT id, email, username
FROM users
WHERE status = 'active'
AND tier = 'premium'
AND last_login >= NOW() - INTERVAL '30 days'
`;
const result = await db.query(sql);
return result.rows;
}
async updateTier(userId, newTier) {
await db.query("UPDATE users SET tier = $1 WHERE id = $2", [newTier, userId]);
}
}
module.exports = UserRepository;
Next, look at how we inject and utilize this storage container safely within our core business rules service file:
// services/analyticsService.js
class AnalyticsService {
// Combine this with Dependency Injection for maximum flexibility
constructor(userRepository) {
this.userRepo = userRepository;
}
async executeMonthlyAudit() {
console.log("📊 Starting premium tier verification checks...");
// 🚀 The service is completely database-agnostic. No SQL/ORM leak!
const targetUsers = await this.userRepo.findActivePremiumUsers();
// Run core business rules on the decoupled array
const auditedRecords = targetUsers.map(user => {
return { ...user, auditedAt: new Date() };
});
return auditedRecords;
}
}
module.exports = AnalyticsService;
🧪 Why This Saves Your Tests
Because your service layer doesn't execute native database statements, you can write super-fast tests by passing a dummy in-memory data collection object:
// tests/analyticsService.test.js
const AnalyticsService = require('../services/analyticsService');
test('should process audits cleanly using an isolated data mock', async () => {
// 1. Mock the repository collection interface entirely in-memory
const fakeUserRepo = {
findActivePremiumUsers: jest.fn().mockResolvedValue([
{ id: 42, email: 'premium@test.com', username: 'john_doe' }
])
};
// 2. Inject the data adapter mock into the service
const service = new AnalyticsService(fakeUserRepo);
const result = await service.executeMonthlyAudit();
// 3. Clean assertion check runs with zero persistence dependencies
expect(result[0].id).toBe(42);
expect(result[0]).toHaveProperty('auditedAt');
});
🧠 Code Smell Test: When should you use a Repository?
Keep an eye out for this indicator during development:
-
The Smell: Your business-level use-case files import SQL utilities (
knex,pg) or ORM engine schemas (PrismaClient,mongoose.model) and contain native data-filtering properties inside their execution flow. - The Fix: Migrate those database-specific queries out of the service rules layer into a dedicated standalone Repository file.
That wraps up Step 8! Your software application architecture is now completely decoupled from persistence details.
Up next, we head into Category 3: Behavioral Patterns with Step 9: The Strategy Pattern, where we will look at how to cleanly swap algorithms at runtime based on real-time application events.
Do you systematically decouple queries into Repository files, or do you find it simpler to invoke ORM code directly inside your controllers for smaller tables? Let’s talk in the comments below!
Top comments (0)