On a backend I work on, user.service.ts had crossed 1,307 lines. Nobody on the team could hold the whole file in their head. New hires bounced off it on day one. Reviews took twice as long because the reviewer did not remember the surrounding code the diff was sitting in, and the blame view was a quilt of contributors going back years.
We split the file into four sub-services behind a stable facade, landed the whole thing in one PR without changing a test, and wrote the procedure up as a reusable Claude skill for the next time.
Two terms before we go further
- Facade, in this post, is a class that exposes the same public methods as the old service did, but delegates each method to the right one of the new sub-services. The old import path keeps working; the internal shape moves.
- Sub-service is one of the new files the mega-service is split into. Each sub-service owns one slice of responsibility (querying, mutating, membership-state, stats) and is named after that slice.
The split we shipped
src/modules/user/
├── user.service.ts # facade — was 1,307 lines, now ~120
├── user-query.service.ts # 349 lines — read paths
├── user-mutation.service.ts # 460 lines — write paths
├── user-membership.service.ts # 288 lines — membership-state changes
├── user-stats.service.ts # 307 lines — derived counts and aggregates
└── user.service.password-reset.spec.ts
The split ran along responsibility inside the service layer, not up through the controller and model layers. Reading a user and updating a user are different jobs on the same domain object, and they grow at different rates. On this service, the read methods stayed small and stable while the write methods kept picking up business rules.
Query — anything that returned a user (or a list of users) without mutating state. findById, findByEmail, findByCompany, searchByKeyword. The query service does not write.
Mutation — anything that wrote a user row (create, update, updatePreferences, softDelete, restore). The mutation service loads a row, mutates it, saves it.
Membership — methods that flip the user's membership state: upgrade tier, downgrade tier, transfer membership, attach to company, detach from company. The state transitions are tangled enough to justify their own file.
Stats — derived counts and aggregates. "How many users in this company are active," "how many memberships expire this month," "how many users by role." Reading across many rows is a different job from finding a single user.
A few methods did not fit any of the four. Some were doing two things and got split before they moved. Some were orchestration — they touched two or more sub-services in a single flow — and stayed on the facade.
The facade — keep the public API stable
The facade is the file every other module imports. Its surface area is identical to the old service: every method other modules called still exists, with the same signature.
// user.service.ts — the facade, after the split
@Service()
export class UserService {
constructor(
private readonly query: UserQueryService,
private readonly mutation: UserMutationService,
private readonly membership: UserMembershipService,
private readonly stats: UserStatsService,
) {}
// ─── Read methods delegate to query ─────────────────────────────────
findById = this.query.findById.bind(this.query);
findByEmail = this.query.findByEmail.bind(this.query);
findByCompany = this.query.findByCompany.bind(this.query);
searchByKeyword = this.query.searchByKeyword.bind(this.query);
// ─── Write methods delegate to mutation ─────────────────────────────
create = this.mutation.create.bind(this.mutation);
update = this.mutation.update.bind(this.mutation);
updatePreferences = this.mutation.updatePreferences.bind(this.mutation);
softDelete = this.mutation.softDelete.bind(this.mutation);
restore = this.mutation.restore.bind(this.mutation);
// ─── Membership ─────────────────────────────────────────────────────
upgradeMembership = this.membership.upgrade.bind(this.membership);
downgradeMembership = this.membership.downgrade.bind(this.membership);
attachMemberToCompany = this.membership.attachToCompany.bind(this.membership);
// ─── Stats ──────────────────────────────────────────────────────────
countActiveByCompany = this.stats.countActiveByCompany.bind(this.stats);
countExpiringMemberships = this.stats.countExpiringMemberships.bind(this.stats);
// ─── Orchestration methods stay here ────────────────────────────────
// Methods that genuinely coordinate across two or more sub-services.
async passwordResetFlow(email: string, newPassword: string) {
const user = await this.query.findByEmail(email);
if (!user) return { success: false };
await this.mutation.updatePassword(user.id, newPassword);
await this.stats.recordSecurityEvent(user.id, "password-reset");
return { success: true };
}
}
The bind-and-assign shape looks unusual on first read, but each method on the facade is the same function object as the one on the sub-service — no wrapper indirection, no extra logging. userService.findById(42) runs with the same overhead it always had.
Orchestration methods stay on the facade because they touch several sub-services in one flow. passwordResetFlow above hits the query, mutation, and stats services in sequence, so it belongs to none of them alone.
How the split landed in one PR
The PR added around sixteen hundred lines and deleted around twelve hundred. Most of it was file moves. The tests did not need to change: because the facade's public API is byte-identical to the old service, every test that imported UserService kept passing untouched. That is the safety net the whole refactor rides on.
Three disciplines made the PR reviewable.
One sub-service per commit. Four code commits, one per sub-service, plus a fifth that thins the facade. The reviewer reads one sub-service at a time.
Method-by-method migration. Each method moved with its tests. If a test in the suite was targeting a single method, that test stayed near the method, in the same commit as the move.
No behavioural changes. Every method moved is byte-identical to what was in the old service. Behavioural refactors — N+1 fixes, added logging, tighter authorization — go in follow-up PRs after the split lands.
The reusable skill — service-facade-refactor
The skill file lives at .claude/skills/service-facade-refactor/SKILL.md in this codebase and in our internal skill library. The next mega-service does not need to re-derive any of this.
A Claude skill is a small Markdown file documenting a reusable procedure in enough detail that an LLM-assisted contributor can apply it without having lived through the original work.
The skill in full:
# Service Facade Refactor
A pattern for splitting a single mega-service file into multiple
responsibility-tagged sub-services behind a stable public facade,
without breaking any caller.
## When to use this skill
A single service file has crossed roughly 800 lines, the team is
avoiding it on PRs, and the file mixes more than one of:
read paths, write paths, state-machine transitions, derived stats.
If the file is large but coherent (one responsibility, just verbose),
this is not the right skill — use a method-extraction refactor instead.
## Inputs
- The mega-service file (path).
- The full test suite (must be green before starting).
- One hour of uninterrupted reading time, before any code moves.
## Steps
1. **Tag every method on the existing service.** Read top-to-bottom.
Each method gets exactly one tag:
- **Read** — returns the entity, no writes.
- **Write** — mutates the entity, may read first.
- **State machine** — tier upgrades, status transitions, lifecycle.
- **Stats** — counts, aggregates, derived values across many entities.
- **Orchestration** — coordinates across two or more of the above.
If a method does not fit one tag, it is doing two things; split
the method *before* you split the service.
2. **Each non-orchestration tag becomes a sub-service.**
File naming: `<domain>-<tag>.service.ts`. Example: `user-query.service.ts`.
3. **Move methods one tag at a time, in their own commit, with
their tests.** The test file moves with the method, or, if the
tests are integration-style, stay in place and reference the
sub-service through the facade.
4. **Rewrite the original service file as a facade.**
- For each method on a sub-service, `bind`-and-assign on the facade:
`findById = this.query.findById.bind(this.query);`
- For orchestration methods (touch two or more sub-services), keep
the implementation on the facade.
5. **Run the full test suite. It should pass with no test changes.**
The facade's public API is byte-identical to the original service.
6. **Land as a single PR with one commit per sub-service plus one
for the facade.** Behavioural changes (N+1 fixes, new logging,
tightened authz) are *follow-up PRs*, not part of the split.
## Output shape
- One facade file, exposing the same methods the original did.
- Four (or fewer) sub-service files, each named by its tag.
- Zero test changes.
- A single PR, multi-commit, readable one sub-service at a time.
## What this is not
- Not a layered split (controller / service / model). The split is
by responsibility within the service layer, not by layer.
- Not a behavioural change. Save the perf and authz fixes for after.
- Not appropriate for files under ~500 lines. The overhead of the
split is worse than the readability win at that size.
Step 1 is where the split gets decided. The tagging walks top-to-bottom on five yes/no questions, and the first "yes" wins.
The skill works across codebases because it does not name a specific domain. Any TypeScript codebase with service classes and a mega-service problem can apply it the same way.
The judgement calls in the skill took longer to write than the steps. The "if the file is large but coherent, this is not the right skill" line at the top and the "what this is not" section at the bottom are what keep the skill from getting misapplied. A skill that only describes the happy path gets used wrong the first time somebody reaches for it.
The next mega-service on this codebase — finance.service.ts, around eleven hundred lines — is queued up for the same treatment.