For a long time, we faced a major refactoring challenge at work. Our goal? Break apart a tightly coupled monolith and transition to microservices, making the system more modular, scalable, and robust.

But there was a problem—the monolithic code was too intertwined. Every part depended on another, making it difficult to extract and separate concerns cleanly. Part of the issue? My own junior decisions played a role in creating the tangled mess.

Here’s how we approached the problem step by step and what we learned about good abstractions, bad abstractions, and refactoring with confidence.


1. The First Step: Splitting the Code into Logical Modules

Since the monolith was a mess, we needed a structured way to extract responsibilities. Instead of trying to rewrite everything from scratch, we started by identifying distinct business logic components:

  • Background Check Module
  • Transactions Module
  • Notification System
  • CRM Gateway

Once we identified these core modules, we did something very unglamorous but highly effective:

✅ We copy-pasted all the logic into their respective modules.

At this stage, we were not refactoring yet. Our goal was to separate responsibilities while keeping everything functional.


2. Establishing a Safety Net: Unit Tests

Once we had the logic split into modules, we wrote unit tests. This step was crucial because:

  • We needed a fail-safe before touching any logic.
  • Even imperfect tests were better than nothing.
  • They helped us identify at least theexpected behavior.

After unit tests were in place, only then did we begin the real refactoring—removing duplicate code, simplifying logic, and improving maintainability.


3. The Dangers of Premature Abstractions

One major mistake we had previously encountered was premature abstraction—where we generalized code too early without fully understanding how it would be used.

The problem?

  • Harder debugging: Over-abstracted code made it difficult to trace what was actually happening.
  • Overcomplicated solutions: Some parts of the system were abstracted too much to the point of being unmanageable.
  • Rigid structures: Instead of making changes easier, these abstractions locked us into unnecessary complexity.

We learned the best approach to abstraction is not to guess but to observe actual usage patterns.


4. The Right Way to Abstract: Let It Emerge Naturally

Instead of forcing abstractions upfront, we looked at what we actually used.

One key insight:
Most of our UI consisted of tables.

That led us to a practical and useful abstraction—a generic table provider that could:

  1. Fetch table configurations from the backend.
  2. Auto-generate tables based on service-specific data.
  3. Use Kafka to listen for updates and refresh data periodically.
  4. Provide a simple API call for the frontend (TableWebClient.callTable(tableName, filter)).

Now, whenever a new service needed a table, we didn’t need to write new API calls or frontend components—it all just worked.

What the backend does:

  • We tell it how the table should look and specify what styles shoudl be applied to each data point.
  • A Kafka listener updates the table based on the service name.
  • Each service sends relevant data using a designated Kafka channel.
  • This process repeats every X time interval, ensuring up-to-date cached data.
  • Now, the frontend simply calls TableWebClient.callTable(tableName, filter), and the table is automatically rendered with no extra effort.

This eliminated a lot of repetitive code while keeping the system flexible and maintainable. I am very proud of this one! 🚀


5. The “Easiest Wins” Approach to Refactoring

Instead of looking for big, complicated refactors, we focused on small, easy-to-execute improvements that made a huge impact.

How We Did It:

  • Tracked function usage—any code that wasn’t called for two months (unless explicitly marked //DO NOT TOUCH) was deleted.
  • Took notes on repeated patterns—instead of assuming what needed refactoring, we observed which parts of the system were actually causing problems.
  • Prioritized small wins—we focused on changes that reduced complexity with minimal risk.

This approach made refactoring manageable instead of overwhelming.


Final Thoughts: Refactoring is About Balance

Refactoring and abstraction are not about making things “fancy”—they’re about making things clear, maintainable, and easy to work with.

Key lessons learned:

Separate concerns first, then refactor. Don’t try to fix everything at once.
Write unit tests before refactoring. Even imperfect tests provide a safety net.
Avoid premature abstractions. Observe how the system is actually used before abstracting.
Start with small, easy refactors. Deleting unused code and simplifying logic makes a bigger impact than over-engineering solutions.

P.S. Never delete the code that has //DO NOT TOUCH OR {THIS WILL HAPPEN} around it