Summary
A story about 43,936 lines of code that couldn't stay together, the courage to refactor, and the surgery that turned one service into three.
---
The Count
43,936 lines of code.
I counted them the night I decided they couldn't stay together.
The identity service had started small. A few endpoints for user registration. Some authentication logic. Basic profile management. The kind of service you spin up in a week and call "good enough."
That was months ago.
Now it was 111 files. 34 controllers. 43,936 lines of TypeScript that had grown together like vines around a single trunk, each new feature wrapping around the last, intertwining until you couldn't tell where one ended and another began.
---
How Monoliths Grow
No one builds a monolith on purpose.
You build a service. It works. You add a feature. Still works. Another feature. Another endpoint. Another model. Another controller.
The KYC verification logic needed user data, so it went in the identity service. The admin dashboard needed to manage users, so it went in the identity service. The authentication flows needed session management, so it went in the identity service.
Each decision made sense in isolation. Each feature was easier to add than to separate.
And then one day you open the codebase and realize you can't change the login flow without risking the KYC verification. You can't update the admin permissions without touching the user registration. You can't refactor the session management without understanding 34 different controllers.
A monolith isn't born. It accumulates.
One reasonable decision at a time.
---
The Symptoms
The codebase was telling me something was wrong. I just wasn't listening.
Symptom one: Every pull request touched files it shouldn't. A change to admin role management modified files in the KYC folder. A fix for registration validation required changes in the authentication module. The boundaries were imaginary.
Symptom two: The tests were coupled. Unit tests for user creation needed to mock KYC services. Integration tests for authentication required the entire admin system to be configured. Nothing could be tested in isolation because nothing existed in isolation.
Symptom three: The TypeScript errors. Not the helpful kind that catch bugs. The kind that cascade, fix one, create three. The type system was trying to enforce boundaries that the code had long abandoned.
Symptom four: The fear. Every deployment felt risky. Every feature felt fragile. The service that should have been the most stable part of the platform was the part I trusted least.
---
The Decision
I could have kept going.
Add more features. Work around the coupling. Write more tests to catch the regressions. Accept that "identity service" really meant "everything related to users, which is everything."
But I kept thinking about bounded contexts.
Domain-Driven Design talks about them, clear boundaries where one domain ends and another begins. Authentication is not the same as KYC. User registration is not the same as admin management. They use some of the same data, but they have different rules, different lifecycles, different reasons to change.
The monolith had erased those boundaries. The refactoring would restore them.
I created a document. Titled it: "svc-identity decomposition."
And then I started drawing lines.
---
The Surgery
The plan was simple. The execution was not.
Step one: Map the domains.
I read every file. Every controller. Every service. I asked one question: what is this actually responsible for?
Three domains emerged:
They had been living together, but they weren't the same thing.
Step two: Define the boundaries.
Each domain became a bounded context. Each context got its own folder structure. Its own use cases. Its own domain errors. Its own repository interfaces.
Clean Architecture gave me the template:
Step three: Cut the dependencies.
This was the painful part.
The KYC service had been directly calling user repository methods. Now it would call a port, an interface that the infrastructure layer would implement. The authentication service had been using the same session model as the admin service. Now each domain had its own session concept, translated at the boundaries.
Every direct dependency became an interface. Every shared model became a translation. Every shortcut I had taken became a proper abstraction.
---
The Numbers
Before:
svc-identity/
├── 111 files
├── 34 controllers
├── 43,936 lines
└── 1 service trying to do everythingAfter:
svc-auth/
├── 41 use cases
├── 30+ domain errors
└── Authentication bounded context
svc-kyc/
├── 38 use cases
├── 28+ domain errors
└── Identity verification bounded context
svc-admin/
├── 42 use cases
├── 32+ domain errors
└── Administration bounded context121 use cases total. 90+ domain-specific error classes. Three services with clear responsibilities.
And the number that mattered most:
TypeScript errors: 0---
What Strict Mode Taught Me
TypeScript strict mode was my safety net.
When you decompose a monolith, you're moving code. Renaming imports. Changing interfaces. Breaking dependencies. Every change is an opportunity for something to go wrong.
Strict mode catches it. Immediately. Precisely.
A function that used to accept any now needs proper types. An optional field that was assumed to exist now requires a null check. A shared model that worked in the monolith now needs explicit translation at the boundary.
The compiler became my refactoring partner. Every error it threw was a boundary it was helping me enforce. Every fix made the architecture more explicit.
When the errors hit zero, I knew the surgery was complete.
---
What I Learned
1. Monoliths aren't bad because they're big.
A 100,000-line service with clear internal boundaries is better than a 10,000-line service where everything depends on everything. Size isn't the problem. Coupling is.
2. The best time to split is before you need to.
I waited until the monolith was painful. I should have split earlier, when the domains first became clear. Refactoring 44,000 lines is harder than refactoring 15,000.
3. Clean Architecture is a scalpel, not a hammer.
The hexagonal pattern gave me the structure to make clean cuts. Use cases became the unit of behavior. Ports became the unit of dependency. Each layer had one job, and the boundaries between layers became the boundaries between services.
4. Domain errors are documentation.
90+ error classes sounds like a lot. But each one documents a specific failure mode. KycDocumentExpiredError tells you more than Error: validation failed. Domain errors make the bounded context's rules explicit.
5. Zero TypeScript errors is a milestone, not a metric.
Hitting zero errors meant the refactoring was structurally complete. It didn't mean the code was correct. Tests still mattered. But the compiler gave me confidence that the pieces fit together.
---
The Aftermath
The three services have been running for months now.
Changes to authentication don't touch KYC. Changes to KYC don't touch admin. Each service can be deployed independently. Each can be tested in isolation. Each has a clear reason to exist.
The 43,936 lines are still there, distributed across three codebases. But they're organized now. Bounded. Each line knows which domain it belongs to.
And when I need to add a new feature, I know exactly where it goes.
---
Closing
I still remember the night I counted the lines.
111 files. 34 controllers. One service that had grown too tangled to trust.
The monolith didn't die dramatically. It was decomposed carefully, methodically, one bounded context at a time.
A monolith isn't bad because it's big.
It's bad when it can't tell you where one thing ends and another begins.
The surgery took weeks. The result will last years.
And the TypeScript compiler, patient and precise, guided every cut.
---
Technical Notes
For anyone facing a similar monolith decomposition:
The goal isn't three services instead of one. The goal is clear boundaries, explicit dependencies, and code that can change without breaking unrelated features.
