DEV Community

Alex Rogov
Alex Rogov

Posted on • Originally published at alexrogov.hashnode.dev

AI Didn't Replace the Architecture Decisions — It Eliminated the Tax

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: UserService had 1,200 lines and handled everything from auth to email to reporting
  • Circular dependencies: OrderService imported UserService which imported NotificationService which imported OrderService
  • 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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
// 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>;
}
Enter fullscreen mode Exit fullscreen mode
// 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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)