Last month I refactored a legacy NestJS service layer — 47 files, 15,000 lines of code tangled with circular dependencies, god services, and business logic buried inside controllers.
The old me would have blocked out 3+ hours, put on headphones, and ground through it file by file. Instead, I finished in 20 minutes. Not because AI wrote the code for me — but because it eliminated the tax I used to pay just to execute decisions I'd already made.
Here's the workflow, the real before/after code, and where AI actually helps versus where it absolutely doesn't.
The Refactoring That Needed to Happen
The codebase was a 2-year-old NestJS monolith. Classic symptoms:
-
God services:
UserServicehad 1,200 lines and handled everything from auth to email to reporting -
Circular dependencies:
OrderServiceimportedUserServicewhich importedNotificationServicewhich importedOrderService - No layer separation: Controllers contained business logic, repositories leaked into route handlers, and Prisma models appeared everywhere
- Zero interfaces: Everything was concrete. Swapping an implementation meant touching 30+ files
The architecture decision was straightforward — extract domain logic into proper layers following Clean Architecture. I'd made that decision in my head in 5 minutes. But executing it? That's where the hours disappeared.
Mapping dependencies. Finding every import chain. Extracting interfaces. Moving files. Updating imports. Running tests after each step. This is the tax — the mechanical, repetitive work that sits between the architectural decision and its implementation.
The 4-Step AI-Assisted Workflow
Step 1: Map the Blast Radius
Before touching any code, I need to understand what I'm dealing with. This is where AI saves the most time.
## CLAUDE.md context for this refactoring
### Current Task
Refactoring UserService (src/services/user.service.ts) — extract into:
- src/domain/user/ (entities, value objects)
- src/application/user/ (use cases, ports)
- src/infrastructure/user/ (repository implementations)
### Rules
- Do NOT change any public API signatures
- Do NOT modify controller routes
- Every extracted file must have an interface in application layer
- Run `npm run typecheck` after each file move
Then I ask Claude to analyze the dependency graph:
Show me every file that imports from UserService,
and every file that UserService imports from.
Group them by layer: controller, service, repository, utility.
What AI gives me in 30 seconds that would take 15-20 minutes manually:
UserService is imported by:
Controllers (4): UserController, AuthController, AdminController, ReportController
Services (3): OrderService, NotificationService, AnalyticsService
Tests (2): user.service.spec.ts, auth.service.spec.ts
UserService imports from:
Repositories (2): PrismaService, RedisService
Services (2): EmailService, NotificationService (⚠️ circular!)
Utils (3): hash.util, date.util, validation.util
External (1): @nestjs/jwt
That circular dependency between UserService and NotificationService? I might have caught it eventually, but AI flagged it in the first pass. That alone saved me from a 30-minute debugging session later.
Step 2: Generate the Migration Plan
This is the critical step where AI shines — but only if you've defined the target architecture clearly.
Based on the dependency analysis, generate a step-by-step migration plan.
Rules:
1. Extract leaf dependencies first (no internal imports)
2. One file per step
3. After each step: npm run typecheck must pass
4. Never break the public API
AI produces something like:
Step 1: Extract User entity → src/domain/user/user.entity.ts
Step 2: Extract UserRepository interface → src/application/user/ports/user.repository.ts
Step 3: Extract CreateUser use case → src/application/user/use-cases/create-user.ts
Step 4: Extract UpdateUser use case → src/application/user/use-cases/update-user.ts
Step 5: Move Prisma implementation → src/infrastructure/user/prisma-user.repository.ts
Step 6: Break circular dep — extract NotificationPort interface
Step 7: Wire up dependency injection in user.module.ts
Step 8: Update controller imports
Step 9: Delete old UserService, update barrel exports
Key insight: I don't blindly follow this plan. I review each step, reorder when needed, and sometimes merge steps. The AI gives me a draft — I give it architectural judgment.
Step 3: Execute in Small Batches
This is where most people misuse AI. They ask it to "refactor the entire service" in one shot. That's a recipe for a broken codebase and a 200-line diff you can't review.
Instead, I execute one step at a time:
Execute Step 1: Extract User entity.
Move the User interface and related types from UserService to src/domain/user/user.entity.ts.
Update all imports. Run typecheck.
Before (buried inside user.service.ts):
// src/services/user.service.ts — 1,200 lines of everything
interface User {
id: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
profile: UserProfile | null;
createdAt: Date;
}
interface UserProfile {
displayName: string;
avatarUrl: string | null;
}
@Injectable()
export class UserService {
constructor(
private prisma: PrismaService,
private redis: RedisService,
private email: EmailService,
private notification: NotificationService,
private jwt: JwtService,
) {}
async findById(id: string): Promise<User | null> {
const cached = await this.redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await this.prisma.user.findUnique({
where: { id },
include: { profile: true },
});
if (user) {
await this.redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
}
return user;
}
async createUser(data: CreateUserDto): Promise<User> {
const hashedPassword = await hash(data.password, 12);
const user = await this.prisma.user.create({
data: { ...data, password: hashedPassword },
});
await this.email.sendWelcome(user.email, user.profile?.displayName);
await this.notification.notifyAdmins('new-user', { userId: user.id });
return user;
}
// ... 1,100 more lines: updateUser, deleteUser, findByEmail,
// generateToken, verifyToken, resetPassword, updateProfile,
// getActivityLog, exportUserData, handleGDPRDeletion...
}
After (Step 1-6 completed — clean layers):
// src/domain/user/user.entity.ts — pure domain, zero dependencies
export interface User {
id: string;
email: string;
role: UserRole;
profile: UserProfile | null;
createdAt: Date;
}
export type UserRole = 'admin' | 'editor' | 'viewer';
export interface UserProfile {
displayName: string;
avatarUrl: string | null;
}
// src/application/user/ports/user.repository.ts — interface only
export interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(data: CreateUserInput): Promise<User>;
update(id: string, data: UpdateUserInput): Promise<User>;
delete(id: string): Promise<void>;
}
// src/application/user/use-cases/create-user.ts — one use case, one file
import { User } from '../../domain/user/user.entity';
import { UserRepository } from '../ports/user.repository';
import { NotificationPort } from '../ports/notification.port';
import { EmailPort } from '../ports/email.port';
import { PasswordHasher } from '../ports/password-hasher.port';
export class CreateUser {
constructor(
private readonly users: UserRepository,
private readonly email: EmailPort,
private readonly notifications: NotificationPort,
private readonly hasher: PasswordHasher,
) {}
async execute(input: CreateUserInput): Promise<User> {
const hashedPassword = await this.hasher.hash(input.password);
const user = await this.users.create({
...input,
password: hashedPassword,
});
await this.email.sendWelcome(user.email, user.profile?.displayName);
await this.notifications.notifyAdmins('new-user', { userId: user.id });
return user;
}
}
// src/infrastructure/user/prisma-user.repository.ts — implementation
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { UserRepository } from '../../application/user/ports/user.repository';
import { User } from '../../domain/user/user.entity';
@Injectable()
export class PrismaUserRepository implements UserRepository {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
async findById(id: string): Promise<User | null> {
const cached = await this.redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await this.prisma.user.findUnique({
where: { id },
include: { profile: true },
});
if (user) {
await this.redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
}
return user;
}
// ... other methods
}
The god service is gone. Each piece has one job, one file, clear dependencies. And the circular dependency? Gone — replaced by a NotificationPort interface in the application layer.
Step 4: Catch What AI Misses
Here's where architectural experience matters. After AI executes the mechanical work, I review for things it consistently gets wrong:
1. Naming consistency. AI might call it UserRepo in one file and UserRepository in another. I enforce naming conventions from my CLAUDE.md.
2. Error handling patterns. AI defaults to throwing generic errors. I replace them with domain-specific error types:
// AI generated this:
if (!user) throw new Error('User not found');
// I change it to this:
if (!user) throw new UserNotFoundError(id);
3. Transaction boundaries. AI doesn't understand your specific transaction requirements. When createUser needs to be atomic (user creation + welcome email + admin notification), I verify the transaction scope is correct.
4. Test coverage gaps. AI updates existing tests but rarely writes new ones for extracted interfaces. I add integration tests for the new repository implementations.
The Numbers
For this specific refactoring (47 files, ~15,000 lines):
| Step | Without AI | With AI | Time Saved |
|---|---|---|---|
| Dependency mapping | 15-20 min | 30 sec | ~97% |
| Migration planning | 20-30 min | 2 min | ~92% |
| Mechanical execution | 90-120 min | 12 min | ~88% |
| Review & corrections | 15 min | 8 min | ~47% |
| Total | ~3 hours | ~23 min | ~87% |
Notice the pattern: AI saves the most time on mechanical, repetitive tasks (mapping, executing). It saves the least on judgment-heavy tasks (review, corrections). This is not a coincidence — it's the fundamental nature of where AI helps.
Where AI Does NOT Help
Let me be clear about what AI cannot do in this workflow:
1. Make the architecture decision. Choosing Clean Architecture over hexagonal, or deciding to extract services versus merge them — that's your job. AI doesn't understand your team's context, your deployment constraints, or your product roadmap.
2. Understand production constraints. The refactoring can't break the API. Existing clients depend on specific response shapes. Migration must be zero-downtime. AI doesn't weigh these trade-offs — you do.
3. Evaluate long-term maintainability. AI optimizes for "compiles and passes tests." It doesn't ask: "Will a new developer understand this in 6 months?" That question requires experience and judgment.
4. Handle the politics. Half of architecture is convincing your team it's the right move. AI can't attend your design review or explain why the refactoring is worth the sprint allocation.
The Mental Model
Think of AI as a force multiplier for execution, not for decision-making.
Architecture Workflow:
1. Analyze (AI helps: fast, thorough)
2. Decide (YOU: experience, judgment, context)
3. Plan (AI helps: generates options, you pick)
4. Execute (AI helps: 10x faster mechanical work)
5. Verify (BOTH: AI runs checks, you review intent)
The engineers who get the most from AI aren't the ones who delegate thinking to it. They're the ones who've done enough refactorings to know what good looks like — and use AI to get there faster.
AI didn't replace my architecture decisions. It eliminated the tax I paid to execute them.
Key Takeaways
- AI eliminates the execution tax — the mechanical, repetitive work between making an architectural decision and implementing it
- Map the blast radius first — dependency analysis is where AI saves the most time (~97%)
- Execute in small batches — one step at a time, typecheck after each. Never ask AI to refactor everything at once
- Review for what AI misses — naming consistency, error handling patterns, transaction boundaries, and test gaps
- The decision is still yours — AI is a force multiplier for execution, not a replacement for architectural judgment
I share daily insights on AI-augmented architecture on Twitter/X. Connect on LinkedIn — this article started as a LinkedIn post and your comments shaped it.
Originally published on my Hashnode blog. Follow me for more AI + Architecture content.
Top comments (0)