Blog/Architecture Drift: How AI Coding Tools Break Your Layer Boundaries
Architecture

Architecture Drift: How AI Coding Tools Break Your Layer Boundaries

8 min read

AI coding tools like Cursor, Copilot, and Claude Code are transforming how developers write software. A prompt produces a working controller in seconds. An autocomplete fills in an entire service method. The code compiles, the tests pass, and the PR gets merged.

Three months later, your clean NestJS architecture is spaghetti. Controllers talk directly to the database. Domain services import ORM-specific types. Modules reach into each other's internals. Nobody remembers when it started — it just crept in, one AI-generated file at a time.

This is architecture drift, and AI tools are accelerating it faster than any human developer ever could.

Why AI Tools Break Your Architecture

AI code generators optimize for one thing: working code. They produce code that compiles, passes type checks, and does what you asked. But they have no concept of your project's architectural boundaries.

When you prompt an AI tool to "add a new endpoint that returns user orders," it will:

  • Import whatever module gets the job done fastest
  • Use the ORM directly if that's the shortest path to data
  • Reach across module boundaries without hesitation
  • Copy patterns from training data that may not match your architecture

The AI doesn't know that your team decided controllers should never touch the database directly. It doesn't know your domain layer should be ORM-agnostic. It doesn't know that NestJS modules should communicate through exported services, not by importing each other's repositories.

These are your rules, and they exist nowhere in the code that the AI can read. They live in architecture decision records, team wikis, onboarding docs, or simply in the team's collective memory. The AI has access to none of it.

Three Real Examples of AI-Generated Architecture Violations

Let's look at three concrete violations that AI tools routinely generate in NestJS projects.

Violation 1: Controller importing directly from infrastructure

You ask the AI to "add an endpoint that returns all active users." It generates this:

// ❌ AI-generated: controller imports PrismaClient directly
import { Controller, Get } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Controller('users')
export class UsersController {
  private prisma = new PrismaClient();

  @Get('active')
  async getActiveUsers() {
    return this.prisma.user.findMany({
      where: { status: 'ACTIVE' },
      select: { id: true, name: true, email: true },
    });
  }
}

This works perfectly. It compiles, it returns the right data, and the AI even used select to limit fields. But it violates a fundamental rule: controllers should never access the database directly.

The correct version goes through a service:

// ✅ Correct: controller delegates to service
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get('active')
  async getActiveUsers() {
    return this.usersService.findActive();
  }
}

The service layer exists for a reason: it's where business logic lives, where you can add caching, validation, and authorization without cluttering the controller. When the controller bypasses it, you lose all of those extension points.

Violation 2: Domain service importing ORM-specific types

You ask the AI to "add a method to create a user with validation." It generates:

// ❌ AI-generated: domain service uses Prisma-specific types
import { Injectable } from '@nestjs/common';
import { Prisma, User } from '@prisma/client';
import { PrismaService } from '../infrastructure/prisma.service';

@Injectable()
export class UserDomainService {
  constructor(private prisma: PrismaService) {}

  async createUser(data: Prisma.UserCreateInput): Promise<User> {
    if (!data.email.includes('@')) {
      throw new Error('Invalid email');
    }
    return this.prisma.user.create({ data });
  }
}

The AI used Prisma.UserCreateInput as the method's input type. This couples your domain logic directly to Prisma. If you ever switch ORMs, or need to use this service in a context without Prisma, every caller breaks.

// ✅ Correct: domain service uses its own interfaces
import { Injectable } from '@nestjs/common';
import { UserRepository } from './ports/user.repository';

interface CreateUserInput {
  email: string;
  name: string;
  role?: string;
}

@Injectable()
export class UserDomainService {
  constructor(private userRepo: UserRepository) {}

  async createUser(data: CreateUserInput): Promise<void> {
    if (!data.email.includes('@')) {
      throw new Error('Invalid email');
    }
    await this.userRepo.create(data);
  }
}

The domain layer defines its own types and depends on an abstract repository port. The infrastructure layer implements that port using Prisma (or any other ORM). This is the dependency inversion principle — and AI tools consistently ignore it.

Violation 3: Cross-module direct import

You ask the AI to "add order creation that checks user credit." It generates:

// ❌ AI-generated: OrdersService directly imports from UsersModule
import { Injectable } from '@nestjs/common';
import { UsersRepository } from '../users/users.repository';
import { OrdersRepository } from './orders.repository';

@Injectable()
export class OrdersService {
  constructor(
    private ordersRepo: OrdersRepository,
    private usersRepo: UsersRepository, // direct cross-module import
  ) {}

  async createOrder(userId: string, items: OrderItem[]) {
    const user = await this.usersRepo.findById(userId);
    if (user.creditBalance < this.calculateTotal(items)) {
      throw new Error('Insufficient credit');
    }
    return this.ordersRepo.create({ userId, items });
  }
}

The AI imported UsersRepository directly from the users module's internals. In NestJS, modules should only access each other through exported services. The repository is an internal implementation detail.

// ✅ Correct: use the exported UsersService from UsersModule
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { OrdersRepository } from './orders.repository';

@Injectable()
export class OrdersService {
  constructor(
    private ordersRepo: OrdersRepository,
    private usersService: UsersService, // public API of UsersModule
  ) {}

  async createOrder(userId: string, items: OrderItem[]) {
    const user = await this.usersService.findById(userId);
    if (user.creditBalance < this.calculateTotal(items)) {
      throw new Error('Insufficient credit');
    }
    return this.ordersRepo.create({ userId, items });
  }
}

This matters because the UsersModule team can refactor their repository, rename it, split it — anything. As long as UsersService.findById keeps working, the OrdersModule is unaffected. Direct repository imports create hidden coupling that makes refactoring dangerous.

Why This Compounds Into Unmaintainable Code

A single violation is harmless. You might not even notice it in code review. But AI tools generate code fast, and developers accept suggestions without checking architectural compliance. Over weeks and months, the violations accumulate:

  • Week 1: One controller imports PrismaClient directly. "It's just one file, we'll fix it later."
  • Week 3: The AI sees the existing pattern and replicates it in three more controllers. Copilot autocompletes import { PrismaClient } because it's now a pattern in your codebase.
  • Week 6: A new developer joins and sees controllers using Prisma directly. They assume that's the team's convention.
  • Week 10: You have 15 controllers bypassing the service layer. Adding caching now requires changing 15 files instead of 1.
  • Week 16: A database migration breaks 15 controllers because they all depend on the exact Prisma schema.

This is the compounding effect. Each violation lowers the barrier for the next one. AI tools accelerate this because they learn from your codebase — once a bad pattern exists, the AI propagates it everywhere.

The cost isn't the violation itself. It's the blast radius of every future change. When your layers are clean, a database change affects the repository layer. When your layers are tangled, a database change touches controllers, services, domain logic, and tests across the entire codebase.

Architecture as Code: Defining Boundaries in radar.yml

The solution is to make your architecture rules machine-readable. If the rules exist only in documentation or team memory, neither AI tools nor human reviewers can enforce them consistently.

Technical Debt Radar uses a radar.yml config file to define architecture boundaries:

# radar.yml — architecture rules as code
architecture:
  preset: nestjs-ddd
  rules:
    - deny: controllers -> repositories
      message: "Controllers must not access repositories directly. Use a service."
    - deny: domain -> infrastructure
      message: "Domain layer must not depend on infrastructure (ORM, HTTP, etc)."
    - deny: "*.module -> *.module"
      message: "Modules must not import other modules' internals. Use exported services."

  layers:
    - name: controllers
      pattern: "**/*.controller.ts"
    - name: services
      pattern: "**/*.service.ts"
    - name: domain
      pattern: "**/domain/**"
    - name: infrastructure
      pattern: "**/infrastructure/**"
    - name: repositories
      pattern: "**/*.repository.ts"

This config does three things:

  • Makes rules explicit — no ambiguity about what's allowed
  • Makes rules enforceable — the scanner reads this file on every run
  • Makes rules version-controlled — architecture decisions live in the repo, not in a wiki

When you run the scan, these rules are applied to every import statement in your codebase. Any violation is flagged with the exact file, line number, and a human-readable message explaining why it's wrong.

How Radar Detects Architecture Violations

When you run npx technical-debt-radar scan . on a codebase with the violations above, you get output like this:

  TECHNICAL DEBT RADAR — Scan Results

  Architecture Violations (3 found)
  ──────────────────────────────────

  CRITICAL  controllers -> repositories
  src/users/users.controller.ts:3
  import { PrismaClient } from '@prisma/client'
  → Controllers must not access repositories directly. Use a service.

  HIGH  domain -> infrastructure
  src/users/domain/user-domain.service.ts:2
  import { Prisma, User } from '@prisma/client'
  → Domain layer must not depend on infrastructure (ORM, HTTP, etc).

  HIGH  *.module -> *.module
  src/orders/orders.service.ts:2
  import { UsersRepository } from '../users/users.repository'
  → Modules must not import other modules' internals. Use exported services.

  ──────────────────────────────────
  3 violations | 1 critical | 2 high
  Status: FAIL — merge blocked

Each violation includes the file path, line number, the offending import, and a clear message about what to do instead. This runs in your CI pipeline, so violations are caught before the PR is merged — not three months later during a painful refactor.

The AI Fix Loop: Scan, Fix, Rescan

Here's the irony: AI tools create these violations, but AI tools can also fix them — if you give them the right context. The workflow is:

# Step 1: Scan and get violations in AI-friendly format
npx technical-debt-radar scan . --format ai-prompt

# Step 2: The output looks like this:
# FIX REQUIRED: src/users/users.controller.ts:3
# RULE: controllers cannot import from repositories/infrastructure
# VIOLATION: import { PrismaClient } from '@prisma/client'
# CONTEXT: Controller should delegate to UsersService
# SUGGESTED FIX: Remove PrismaClient import, inject UsersService,
#   move query logic to UsersService.findActive()

# Step 3: Paste into your AI tool (Cursor, Claude Code, etc.)
# The AI generates correct fixes with proper layer separation

# Step 4: Rescan to verify
npx technical-debt-radar scan .
# 0 violations — merge unblocked

The --format ai-prompt flag outputs violations in a structured format that AI tools understand. Each violation includes enough context for the AI to generate the correct fix — including which layer the code should move to and which service to inject.

This creates a virtuous cycle:

  • AI generates code fast (but may violate boundaries)
  • Radar catches violations instantly (before merge)
  • AI fixes violations with context (the right way)
  • Radar verifies the fix (zero violations)

The result: you keep the speed of AI-assisted development without sacrificing architectural integrity.

Start Enforcing Boundaries Today

Architecture drift is the most expensive form of technical debt because it affects every future change. And AI tools are making it happen faster than ever.

The fix is simple: make your rules machine-readable and enforce them on every PR.

Run a scan on your codebase right now:

npx technical-debt-radar scan .

First scan is free, no account needed. It takes less than 10 seconds and will show you every architecture violation in your project — including the ones your AI tools introduced last week.

Detect these patterns automatically

Run one command. Get a full report in 10 seconds. No account needed.

npx technical-debt-radar scan .

Create a free account for unlimited scans and PR merge blocking.

Share:TwitterLinkedIn

Get Node.js architecture insights

No spam, unsubscribe anytime.