Eliminating Code Duplication with SOLID Principles
A Real-World Application of the Open/Closed Principle in Refactoring Repository Logic
Imagine working in a team where every new feature feels harder than it should. That was our situation in a financial data reporting project. The problem? Technical debt, especially in the repository layer that fetched income and expense transactions.
Almost half the code in our IncomeRepository and ExpenseRepository was the same. They were basically twins: same SQL queries, same parameters, same logic, and even the function names looked alike. The only real difference was a small change in the WHERE clause.
It slowly became a real blocker for the team’s productivity. In this article, I’ll show how we applied SOLID principles to refactor the code. This move didn’t just clean up duplication, it made our codebase easier to maintain and improved the way our team worked.
Where the Duplication Happened
Our project needed to fetch two kinds of financial data: income and expense transactions. At first, we had two separate repositories, which might sound fine, until you look at the duplicated code.
Here’s a simplified version of the getTotalTransaksiMasuk function in the IncomeRepository, and getTotalTransaksiKeluar in the ExpenseRepository. Both are supposed to return total income or expense transaction data:
IncomeRepository
const IncomeRepository = (Db) => {
return {
getTotalTransaksiMasuk: async ({ partnerId, mergeStatementUuid, startDate, endDate }) => {
let result = null;
if (mergeStatementUuid === "all") {
result = await Db.query(
`SELECT SUM(CAST(dsgs.value AS DECIMAL(18,2))) AS total_masuk
FROM detail_statement ds
JOIN detail_statement_global_summary dsgs ON ds.statement_id = dsgs.detail_statement_id
JOIN statement s ON ds.statement_id = s.id
JOIN merge_statement ms ON ms.id = s.merge_id
WHERE s.partner_id = ?
AND dsgs.key = 'crSum'
AND ds.period >= ?
AND ds.period <= ?`,
[partnerId, startDate, endDate],
);
} else {
// logic for handling a specific merge statement UUID
}
// ...and many more similar queries
};
};ExpenseRepository
const ExpenseRepository = (Db) => {
return {
getTotalTransaksiKeluar: async ({ partnerId, mergeStatementUuid, startDate, endDate }) => {
let result = null;
if (mergeStatementUuid === "all") {
result = await Db.query(
`SELECT SUM(CAST(dsgs.value AS DECIMAL(18,2))) AS total_keluar
FROM detail_statement ds
JOIN detail_statement_global_summary dsgs ON ds.statement_id = dsgs.detail_statement_id
JOIN statement s ON ds.statement_id = s.id
JOIN merge_statement ms ON ms.id = s.merge_id
WHERE s.partner_id = ?
AND dsgs.key = 'dbSum'
AND ds.period >= ?
AND ds.period <= ?`,
[partnerId, startDate, endDate],
);
} else {
// logic for handling a specific merge statement UUID
}
// ...and many more similar queries
};
};See the difference? Probably not much, and that’s exactly the issue.
Only three things changed: the repository name, the function name, and the key value ('crSum' vs 'dbSum'). Everything else was copy-pasted. It seemed efficient at first, but in practice, it slowed us down.
Why It Became a Problem
Even though the duplicated code passed all our early unit tests, it caused bigger issues as the project grew. These problems didn’t show up right away, but they slowly made our work harder.
- Onboarding friction: When new developers joined, they spent too much time comparing files line by line. Since the repositories were almost the same, it was hard to spot the differences.
- Maintenance inefficiency: Any time a feature changed, we had to update two different places with nearly the same logic. It slowly felt very repetitive.
- Inconsistent updates: Sometimes we improved one repository but forgot the other. Over time, that led to uneven quality.
What Is SOLID and Why It Helped
To fix these problems, we turned to the SOLID principles, a set of design guidelines that help developers write better code. They were introduced by Robert C. Martin (Uncle Bob) and are widely used to improve software structure.
Here’s a quick summary of what each letter stands for:
- S — Single Responsibility Principle
A class or function should only do one thing and have one reason to change. - O — Open/Closed Principle:
Code should be open for extension, but closed for modification. - L — Liskov Substitution Principle
If a class works, you should be able to replace it with its subclass without breaking anything. - I — Interface Segregation Principle
Don’t force code to depend on things it doesn’t use. - D — Dependency Inversion Principle
Code should depend on abstractions, not concrete implementations.
You can find a more detailed explanation here on DigitalOcean.
Do We Really Need to Apply SOLID?
Do we really need to change something that works? The code returned the correct data and passed all the tests. At first glance, a refactor might seem like extra work.
But we took a step back and asked ourselves:
- Will this logic change soon? Yes. For example, if a client later wants the average instead of the sum, we’d have to update the same logic in every duplicated query.
- Is debugging easy? Not really. As shown earlier, even small differences between queries were hard to spot.
- Will we write more similar queries? Definitely. Since both income and expense data use the same database schema, more near-identical queries were likely.
After discussing this with the team and reviewing future requirements, we agreed: yes, refactoring was the right move. The chance of changes and the pattern of duplication meant that applying SOLID would save us time in the long run.
How SOLID Principles Applied: The Power of the Open/Closed Principle
After evaluating our pain points, the Open/Closed Principle (OCP) stood out as the clearest solution. Our main issue wasn’t just duplication, it was modifying the same logic over and over, instead of extending it.
Both IncomeRepository and ExpenseRepository had nearly identical behavior. The only real differences were:
- The function names.
- The alias used in the
SELECTclause (total_masukvstotal_keluar). - The value of
dsgs.keyin the SQL query ('crSum'vs'dbSum').
Thus, we needed a generic and extendable solution.
The Shared Factory
We introduced a new module called transaction-repository-factory.js. This became a shared central hub for all that duplicated logic. It holds the base implementation, and we simply extend it for income and expense transactions.
The transaction-repository-factory.js looks like this:
const TransactionRepository = ({ Db, mainKey, nonBusinessKeys = [] }) => {
// Setup logic, shared helpers
return transactionRepository;
};
module.exports = TransactionRepository;Inside this factory, we expose a shared method like getTotalTransaksi:
transactionRepository.getTotalTransaksi = async ({partnerId, mergeStatementUuid, startDate, endDate}) => {
if (!mergeStatementUuid) throw new Error("Merge ID harus ada");
const buildBaseQuery = (isSpecific) => {
const base = `
SELECT SUM(CAST(dsgs.value AS DECIMAL(18,2))) AS total
...
WHERE s.partner_id = ? AND dsgs.key = ?
`;
...
return base + specificClause + filters;
};
const { query, values } = buildQuery({
mergeStatementUuid,
baseQuery,
params,
});
const result = await Db.query(query, values);
const total = Number(result[0]?.[0]?.total || 0);
return { totalTransaksi: total.toFixed(2) };
};This method handles both income and expense logic using the same structure. Since both transaction types have the same database table, this pattern fits perfectly.
By putting all the shared logic in the factory, we created a component that’s closed for modification but open for extension.
The Clean and Extendable Repositories
Here’s how the final IncomeRepository looks now:
Get Kristoforus Adi Himawan’s stories in your inbox
Join Medium for free to get updates from this writer.
IncomeRepository
const IncomeRepository = (Db) => {
return TransactionRepository({
Db,
mainKey: "crSum",
nonBusinessKeys: [
"crSelfSum",
"crP2PLendingSum",
"crMultifinanceSum",
"crOtherLoanSum",
"crTlkklrSum",
"crEarnedInterestSum",
],
});
};This is where OCP shines. The income repository doesn’t duplicate a single query. But, it inherits everything from the factory. All the shared logic stays untouched.
Why This Architecture Worked for Us
- Isolated testing: Each repository can be tested on its own with clear input/output behavior.
- Consistent codebase: All future repositories follow the same structure.
- Easy to extend: Need a new transaction type? Just plug in a new key. No need to modify the shared factory.
This pattern turned what used to be fragile and duplicated code into a maintainable and reusable system. It also made development faster and less error-prone.
Benefits
This refactoring brought immediate and measurable improvements to our workflow:
(1) Effortless Repository Creation
Adding new key in nonBusinessKeys is now as simple as updating the repository definition. No duplicated logic, no custom queries.
const IncomeRepository = (Db) => {
return TransactionRepository({
Db,
mainKey: "crSum",
nonBusinessKeys: [
"crSelfSum",
"crP2PLendingSum",
"crMultifinanceSum",
"crOtherLoanSum",
"crTlkklrSum",
"crEarnedInterestSum",
// Just add it here
],
});
};This shift improves maintainability.
(2) Scalable and Reusable Design
Creating a new repository now requires just one factory call:
ExpenseRepository
const ExpenseRepository = (Db) => {
return TransactionRepository({
Db,
mainKey: "dbSum",
nonBusinessKeys: [
"dbSelfSum",
"dbP2PLendingSum",
"dbMultifinanceSum",
"dbOtherLoanSum",
"dbTlkklrSum",
],
});
};No copy-pasting. No risk of missing filters or forgetting join conditions. Just a clean setup that scales with your needs.
(3) Zero Duplication
Before this change:
expense_repository.js: 49.9% duplicationincome_repository.js: 48.8% duplication
After the refactor:
- Duplication rate: 0%
The logic is now centralized, clean, and significantly easier to manage.
Was This Over-Engineering?
Of course, not everything was perfect. This architecture introduced a layer of abstraction that wasn’t immediately intuitive, especially for teammates used to straightforward SQL queries.
Take this helper, for example:
const buildBaseQuery = (isSpecific) => {
const base = `
SELECT SUM(CAST(dsgs.value AS DECIMAL(18,2))) AS total
FROM detail_statement ds
JOIN ...
WHERE s.partner_id = ? AND dsgs.key = ?
`;
const specificClause = isSpecific ? `AND ms.uuid = ?\n` : "";
const filters = `
AND ds.period >= ? AND ds.period <= ?
AND ds.deleted_at IS NULL AND dsgs.deleted_at IS NULL
AND s.deleted_at IS NULL AND ms.deleted_at IS NULL
`;
return base + specificClause + filters;
};Compared to a regular SQL query, this abstracted method can be less intuitive for developers new to the codebase.
Is It Worth It?
Abstraction improves flexibility, but often at the cost of initial readability. To make the refactoring process smoother, we focused not only on the code but also on the people behind the code. Here’s how I addressed the challenges:
- Held knowledge sharing sessions
- Created documentation
- Added PR and code comments explaining the new structure
I even created a dedicated Google Docs document to clearly explain how SOLID principles were applied.
In the end, I can say that applying SOLID in this case was worth it. But it’s important to ensure your team understands why you’re applying these principles.
Make sure they grasp both the reasoning behind the new abstraction. Also, don’t hesitate to use metrics to justify the refactoring to your team or even to the client.
Lessons Learned
This wasn’t just a refactor, it was a reminder of how good architecture improves team performance.
- Technical debt is real: Short-term duplication led to long-term slowdown.
- SOLID pays off: Principles like OCP guide real-world scalability.
- Design patterns matter: The Factory Pattern helped us implement OCP efficiently.
- Balance is essential: Too much abstraction can backfire. The right design is the one that works for your team.
Conclusion
SOLID may seem like over-engineering for something as “simple” as repositories, but this refactoring actually brings significant long-term benefits in maintainability and reusability. Instead of duplicating nearly identical logic across income and expense repositories, abstracting the shared behavior into a generic TransactionRepository eliminates redundancy, reducing duplication from more than 50% to 0%.
Applying the SOLID principles through a design pattern like the factory method allowed the team to enforce a consistent structure across multiple repositories. In the end, small design decisions can lead to big wins in code quality and team collaboration. But like most things in software, balance is key.






