Todos los artículos

// Construir con IA sin perder el control

Ingeniería de Software en la Era de IA: Más allá del Vibe-Coding

Cómo prevenir deuda técnica con LLMs: patrones de codegen, mantenibilidad y prácticas anti-patterns en desarrollo asistido por IA.

6 de julio de 202617 min de lectura

Introducción

La promesa de la IA generativa es seductora: haz click, describe lo que quieres, obtén código funcional en segundos. Pero esta narrativa del "vibe-coding" — donde la intuición sustituye al diseño deliberado — es un peligroso anti-pattern que está generando una crisis silenciosa de deuda técnica.

Como Tech Lead, he visto equipos donde la productividad aparente con LLMs se traduce en sistemas inmantenibles, pruebas inexistentes y arquitecturas que colapsan bajo el primer cambio de requisitos. Este artículo explora cómo navegar esta nueva realidad: usando IA como herramienta de aceleración mientras preservamos los principios fundamentales de ingeniería de software.


El problema: La ilusión de la velocidad

La trampa del "copiar y pegar" generativo

El flujo de trabajo típico con LLMs parece perfecto: describe un feature, el modelo genera código, lo pegues en tu repo, funciona, commit. Pero esta superficialidad oculta profundas fallas estructurales.

⚠️El anti-pattern del cargo-cult con IA

El mayor peligro no es que los LLMs generen código incorrecto — es que generen código que funciona pero no escala. Arquitecturas que parecen sólidas en demos pero colapsan en producción cuando llegan los patrones de uso reales.

Consideremos un ejemplo típico: un equipo de backend usa GPT-4 para generar microservicios rápidamente. Cada servicio parece correcto individualmente, pero la arquitectura carece de:

  • Contratos explícitos: Los servicios comunican via DTOs implícitos que el LLM generó "bueno para ahora"
  • Manejo de errores consistente: Cada servicio tiene su propia estrategia de error handling
  • Observabilidad: Logs estructurados inexistente, métricas inconsistentes
  • Tests: Los LLMs generan tests triviales que no cubren edge cases reales

Deuda técnica invisible

La deuda técnica generada por LLMs es particularmente insidiosa porque se esconde detrás de código que funciona. No son los errores obvios que un linter atrapa — son decisiones arquitectónicas que parecen correctas en el momento pero pagan dividendos negativos meses después.

1

Identificación del problema

Un desarrollador junior usa Claude para generar un servicio de autenticación. El código funciona en desarrollo, así que se mergea a producción sin revisión de arquitectura.

2

Acumulación silenciosa

Tres meses después, el equipo necesita agregar multi-tenancy. Descubren que el servicio de autenticación tiene dependencias circulares con otros servicios, haciendo imposible la arquitectura multi-tenant sin una reescritura completa.

3

Pago de deuda

El equipo gasta 2 semanas refactoring, lo cual es más tiempo que si hubieran diseñado la arquitectura correctamente desde el inicio.


Core Concept: IA como amplificador, no sustituto

El principio de responsabilidad compartida

La metáfora correcta no es "el LLM es mi co-piloto" — es más bien "el LLM es un amplificador de mis capacidades". La arquitectura, las decisiones de diseño y la responsabilidad técnica siguen siendo humanas.

La división de responsabilidades debe ser clara:

ResponsabilidadLLMHumano
Generación de código boilerplate
Diseño arquitectónico
Implementación de patrones conocidos
Trade-offs de diseño
Test coverage básico⚠️
Code reviews de profundidad
Optimización performance⚠️

El framework de responsible AI coding

He desarrollado un framework que implementé en varios equipos para guiar el uso de LLMs en producción:

python
# framework/ai_coding_guardrails.py

from typing import Optional, List
from dataclasses import dataclass

@dataclass
class AICodeReviewChecklist:
    """Checklist para code reviews de código generado por IA"""
    
    # 1. Arquitectura y diseño
    follows_architectural_patterns: bool
    respects_layer_boundaries: bool
    has_proper_abstractions: bool
    
    # 2. Calidad del código
    has_type_hints: bool
    follows_style_guide: bool
    has_documentation: bool
    
    # 3. Testing y validación
    has_unit_tests: bool
    tests_cover_edge_cases: bool
    has_integration_tests: bool
    
    # 4. Mantenibilidad
    is_dry_compliant: bool
    has_proper_error_handling: bool
    is_configurable: bool
    
    # 5. Security
    follows_security_best_practices: bool
    sanitizes_inputs: bool
    has_proper_auth_checks: bool
    
    def score(self) -> float:
        """Calcula porcentaje de cumplimiento"""
        fields = [
            self.follows_architectural_patterns,
            self.respects_layer_boundaries,
            self.has_proper_abstractions,
            self.has_type_hints,
            self.follows_style_guide,
            self.has_documentation,
            self.has_unit_tests,
            self.tests_cover_edge_cases,
            self.has_integration_tests,
            self.is_dry_compliant,
            self.has_proper_error_handling,
            self.is_configurable,
            self.follows_security_best_practices,
            self.sanitizes_inputs,
            self.has_proper_auth_checks,
        ]
        return sum(fields) / len(fields) * 100
La regla del 80-20 con LLMs

Usa LLMs para el 80% del trabajo rutinario (boilerplate, patrones estándar, tests básicos), pero reserva el 20% crítico (arquitectura, trade-offs, edge cases) para revisión humana profunda. Los LLMs son excelentes para acelerar pero no pueden substituir el juicio técnico.


Implementation: Patrones producción-ready

Patrón 1: LLM-assisted Repository Pattern

El Repository Pattern es ideal para generación asistida por IA porque sigue patrones bien definidos que los LLMs dominan, pero requiere diseño humano para boundaries correctas.

typescript
// BEFORE: Código generado por LLM sin revisión de arquitectura
// ❌ Antes: Violación de separation of concerns, mezcla lógica de negocio con datos

class UserService {
  async getUser(id: string) {
    // LLM generó esto - mezcla concerns
    const db = await Database.connect();
    const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
    
    // Lógica de negocio hardcodeada en el método
    if (user.is_premium) {
      const premiumFeatures = await db.query(
        'SELECT * FROM premium_features WHERE user_id = $1', 
        [id]
      );
      return { ...user, features: premiumFeatures };
    }
    
    return user;
  }
  
  async createUser(userData: any) {
    // Sin validaciones, sin transaction handling
    const db = await Database.connect();
    return await db.query('INSERT INTO users ...', [userData]);
  }
}
// Violación de separation of concerns
// Lógica de negocio mezclada con acceso a datos
// Sin abstracciones reusables
class UserService {
  async getUser(id: string) {
    const db = await Database.connect();
    const user = await db.query(...);
    
    // Lógica de negocio hardcodeada
    if (user.is_premium) {
      const premiumFeatures = await db.query(...);
      return { ...user, features: premiumFeatures };
    }
    return user;
  }
  
  async createUser(userData: any) {
    // Sin validaciones, sin transactions
    const db = await Database.connect();
    return await db.query(...);
  }
}

Patrón 2: Type-safe API Contracts con LLM assist

Los LLMs son excelentes generando código TypeScript, pero necesitan guías humanas para crear contratos de API robustos.

python
# BEFORE: Contrato API sin type safety
# ❌ Antes: LLM generó esto sin pensamiento en versioning o backwards compatibility

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class UserResponse(BaseModel):
    # Sin versioning, sin metadata de deprecation
    id: str
    name: str
    email: str
    preferences: dict  # ❌ Type inseguro
    
@app.get("/users/{id}")
async def get_user(id: str):
    # Sin response model, sin error handling estructurado
    user = db.get_user(id)
    return user
# Sin versioning, sin type safety
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class UserResponse(BaseModel):
    id: str
    name: str
    email: str
    preferences: dict  # ❌ Type inseguro
    
@app.get("/users/{id}")
async def get_user(id: str):
    # Sin response model, sin error handling
    user = db.get_user(id)
    return user

Patrón 3: Testing金字塔 con LLM assistance

Los LLMs son excelentes generando tests unitarios triviales, pero necesitan dirección humana para construir testing pirámides efectivas.

Nivel de testLLM CapabilityHuman RequiredProduction Ready
Unit tests (bottom 70%)✅ Excelente❌ Mínimo✅ Con templates
Integration tests (20%)⚠️ Bueno con guía✅ Crítico✅ Con arquitectura
E2E tests (top 10%)❌ Limitado✅ Esencial✅ Manual + LLM assist
Performance tests❌ Pobre✅ Requerido✅ Especializado
Security tests⚠️ Básico✅ Expertise✅ Domain knowledge
typescript
// ✅ Testing pyramid con LLM assistance + human design
// tests/integration/userService.integration.test.ts

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { UserService } from '@/services/UserService';
import { UserRepository } from '@/repositories/UserRepository';
import { TestDatabase } from '@/tests/utils/TestDatabase';

describe('UserService Integration Tests', () => {
  let userService: UserService;
  let testDb: TestDatabase;
  
  beforeEach(async () => {
    // LLM generó el boilerplate, pero diseñamos el setup
    testDb = new TestDatabase();
    await testDb.migrate();
    await testDb.seed();
    
    const userRepo = new UserRepository(testDb.connection);
    userService = new UserService(userRepo);
  });
  
  afterEach(async () => {
    await testDb.cleanup();
  });
  
  describe('User creation flow', () => {
    it('should create user with valid email', async () => {
      // Test generado por LLM basado en nuestros requisitos
      const userData = {
        name: 'Test User',
        email: '[email protected]',
        preferences: {}
      };
      
      const user = await userService.createUser(userData);
      
      expect(user.id).toBeDefined();
      expect(user.email).toBe(userData.email);
      
      // Verification step que el LLM no hubiera pensado
      const dbUser = await testDb.query(
        'SELECT * FROM users WHERE id = $1',
        [user.id]
      );
      expect(dbUser.length).toBe(1);
    });
    
    it('should reject invalid email format', async () => {
      // Edge case que LLM no cubre sin dirección humana
      const userData = {
        name: 'Test User',
        email: 'invalid-email', // ❌ Sin @
        preferences: {}
      };
      
      await expect(userService.createUser(userData))
        .rejects.toThrow('ValidationError');
    });
    
    it('should handle concurrent user creation', async () => {
      // Race condition que requiere testing humano
      const userData = {
        name: 'Test User',
        email: '[email protected]',
        preferences: {}
      };
      
      const promises = Array(10).fill(null).map(() => 
        userService.createUser(userData)
      );
      
      const results = await Promise.allSettled(promises);
      
      // Solo uno debería exitar, el resto throw
      const successCount = results.filter(r => r.status === 'fulfilled').length;
      expect(successCount).toBe(1);
    });
  });
});
El secreto: LLM como generador de templates, no de test strategy

Usa LLMs para generar el boilerplate de tests, pero diseña la testing strategy manualmente. Los LLMs cubren el 70% de casos happy path y edge cases obvios, pero los patrones de concurrencia, race conditions, y scenarios complejos requieren diseño humano.


Lessons Learned: Anti-patterns comunes

Anti-pattern 1: The Prompt-and-Pray Approach

Síntomas: Desarrolladores generan código complejo con un solo prompt largo y mergean sin revisión profunda.

Por qué falla: Los LLMs tienen limitaciones en contexto y reasoning complejo. Un prompt de 1000 palabras para una arquitectura completa generará código que parece correcto pero tiene decisiones subóptimas.

Solución: Iterative prompting con checkpoints humanos.

python
# ✅ Better approach: Iterative development with checkpoints

class CodeGenerationStrategy:
    """Estrategia de codegen con checkpoints humanos"""
    
    def __init__(self, llm_client):
        self.llm = llm_client
        self.checkpoints = []
    
    def generate_feature(self, feature_spec: dict) -> str:
        """Genera código con checkpoints de revisión"""
        
        # Step 1: Architectural design (human review required)
        architecture_prompt = self._build_architecture_prompt(feature_spec)
        architecture = self.llm.generate(architecture_prompt)
        
        # Checkpoint 1: Architecture review
        self.checkpoints.append({
            'step': 'architecture',
            'output': architecture,
            'requires_review': True
        })
        
        # Step 2: Interface definitions (can be auto-accepted)
        interfaces = self._generate_interfaces(architecture)
        
        # Checkpoint 2: Interface validation
        self.checkpoints.append({
            'step': 'interfaces',
            'output': interfaces,
            'requires_review': False  # Auto-accept with patterns
        })
        
        # Step 3: Implementation (human review for complex logic)
        implementation = self._generate_implementation(interfaces)
        
        # Checkpoint 3: Code review (especially for business logic)
        self.checkpoints.append({
            'step': 'implementation',
            'output': implementation,
            'requires_review': True  # Business logic always reviewed
        })
        
        return self._merge_outputs()

Anti-pattern 2: The Black Box Monolith

Síntomas: Un solo módulo generado por LLM que implementa múltiples concerns sin separación clara.

Por qué falla: El LLM optimiza para el caso específico del prompt, no para mantenibilidad o extensibilidad.

Solución: Modular decomposition prompts con bounded context.

typescript
// ✅ Bounded context prompting

// Prompt 1: Domain models only
// "Generate TypeScript interfaces for a user management domain with authentication,
// authorization, and preferences. Include type-safe enums and validation rules."

interface UserDomain {
  // Domain models, no implementation
}

// Prompt 2: Repository interfaces only
// "Based on these domain models, generate repository interfaces with clear separation
// of concerns between users, preferences, and authentication."

interface UserRepository {
  // Interface contracts, no implementation
}

// Prompt 3: Service layer implementation
// "Implement the UserService using these repository interfaces. Focus on business logic,
// not data access details. Include proper error handling and transaction management."

class UserService {
  // Business logic implementation
}

// Prompt 4: Infrastructure implementation
// "Implement the UserRepository using PostgreSQL. Focus on data access, queries,
// and connection management. No business logic here."

class PostgresUserRepository {
  // Data access implementation
}
🚨El costo del refactor tardío

He visto equipos donde un módulo de 500 líneas generado por LLM se expande a 2000 líneas en 3 meses, haciéndolo imposible de refactor sin riesgo de bugs. La inversión en diseño modular inicial paga dividendos exponenciales.

Anti-pattern 3: Testing Afterthought

Síntomas: Tests generados como paso final, no parte integral del desarrollo.

Por qué falla: Los LLMs generan tests que verifican lo que el código hace, no lo que debería hacer según los requisitos.

Solución: Test-first approach con LLM assistance para test definition, luego implementation.

python
# ✅ Test-first development with LLM assistance

class TestDrivenLLMWorkflow:
    """Workflow TDD asistido por LLM"""
    
    def develop_feature(self, feature_spec: dict):
        """Desarrolla feature con enfoque TDD"""
        
        # Step 1: Human defines requirements
        requirements = feature_spec['requirements']
        
        # Step 2: LLM generates test scenarios
        test_scenarios = self.llm.generate_test_scenarios(
            requirements=requirements,
            include_edge_cases=True,
            include_security_tests=True
        )
        
        # Step 3: Human reviews and refines test scenarios
        approved_scenarios = self.review_test_scenarios(test_scenarios)
        
        # Step 4: LLM generates test code based on approved scenarios
        test_code = self.llm.generate_test_code(
            scenarios=approved_scenarios,
            test_framework='pytest'
        )
        
        # Step 5: Tests fail (red phase) - verify they fail as expected
        self._run_tests(test_code)
        assert self._tests_are_failing(), "Tests should fail initially"
        
        # Step 6: LLM generates implementation to pass tests
        implementation = self.llm.generate_implementation(
            requirements=requirements,
            failing_tests=test_code
        )
        
        # Step 7: Tests pass (green phase)
        self._run_tests_with_implementation(test_code, implementation)
        assert self._tests_are_passing(), "Implementation should pass tests"
        
        # Step 8: Human reviews implementation
        self._code_review(implementation)
        
        return {
            'tests': test_code,
            'implementation': implementation
        }

Metrics y KPIs: Midiendo el impacto real

Métricas cuantitativas

Para evaluar si tu equipo está usando LLMs efectivamente, he implementado estas métricas en varios proyectos:

MétricaObjetivoComo medirThreshold
LLM Code CoveragePorcentaje de código generado por LLMCommits con metadata de LLM vs total< 70% (no exceder)
Technical Debt RatioDeuda técnica introducida vs resueltaSonarQube debt ratio por sprint< 5% crecimiento/mes
Test Coverage RetentionCobertura de tests en código LLM-genCode coverage report por módulo> 80% (igual que manual)
Review Time SavingsTiempo de code review ahorcadoTime tracking pre vs post LLM> 30% reducción
Bug RateBugs en producción por commitIssue tracker linked to commits< 10% incremento
Refactor FrequencyFrecuencia de refactors necesariosCount de refactor commits/mes< 5% de total commits

Métricas cualitativas

Las métricas cuantitativas no cuentan la historia completa. En mis equipos, también evalúamos:

Code Review Quality Score (CRQS):

python
def calculate_crqs(review: dict) -> float:
    """Calcula calidad de code review de código LLM-gen"""
    
    scores = {
        'architecture_adherence': 0.30,  # ¿Respeta patrones arquitectónicos?
        'error_handling': 0.20,          # ¿Manejo de errores robusto?
        'security_practices': 0.15,     # ¿Sigue security best practices?
        'test_coverage': 0.15,          # ¿Tests apropiados?
        'documentation': 0.10,          # ¿Documentación clara?
        'maintainability': 0.10         # ¿Code legible y mantenible?
    }
    
    total_score = sum(
        review.get(criterion, 0) * weight
        for criterion, weight in scores.items()
    )
    
    return total_score

# Usage: solo merge código LLM-gen con CRQS > 0.80

Team Knowledge Retention:

  • ¿Los developers entienden el código generado por LLM?
  • ¿Pueden explicar y defender las decisiones arquitectónicas?
  • ¿Hay ownership mental sobre el codebase?
⚠️La métrica más importante: Ownership

He visto equipos donde el "not my code, it's LLM's code" mentalidad lleva a desastre. Los developers deben sentir ownership sobre todo el código, sin importar quién lo escribió.


Conclusion

La era de la IA generativa no es el fin de la ingeniería de software — es su evolución. Los principios fundamentales de diseño, arquitectura y mantenibilidad son más relevantes que nunca. Los LLMs no sustituyen la necesidad de pensamiento arquitectónico deliberado; aceleran la ejecución de decisiones bien diseñadas.

Los equipos exitosos en esta nueva era comparten tres características:

  1. Disciplina arquitectónica: LLMs son herramientas de implementación, no sustitutos de diseño arquitectónico. La responsabilidad de decisiones de diseño permanece humana.

  2. Iteración con checkpoints: Code generation por fases con revisiones humanas en puntos críticos. No "prompt and pray", sino "prompt, review, refine".

  3. Ownership compartido: Todo el código, sea humano o LLM-generado, es responsabilidad del equipo. No existe el código "not mine" en producción.

La próxima vez que uses un LLM para generar código, recuerda: la velocidad de desarrollo es una métrica superficial. La verdadera medida es la calidad mantenible del software que entregas a producción. Los LLMs nos dan velocidad — nosotros debemos proveer dirección.

Puntos clave para Tech Leads
  • Implementa guías claras para uso de LLMs en tu equipo
  • Establece checkpoints humanos en code reviews de código LLM-gen
  • Monitorea métricas de deuda técnica específicas para código asistido por IA
  • Prioriza ownership compartido sobre productividad aparente
  • Invierte en templates y patrones que los LLMs pueden seguir consistentemente

La ingeniería de software en la era de IA no es sobre menos pensamiento — es sobre pensamiento más estratégico con herramientas más poderosas. Los vibes van y vienen; la buena arquitectura perdura.


Referencias y Lectura Recomendada


¿Tu equipo ha adoptado prácticas específicas para código asistido por LLMs? Me encantaría escuchar qué funciona y qué no en tu contexto.

Referencias rápidas

Vista general

Serie y continuidad

Este post aún no forma parte de una serie.

Recursos externos

Incluye recursos adicionales en el frontmatter para que aparezcan aquí.

También te puede interesar

Artículos relacionados

// newsletter

¿Te sirvió este artículo?

Recibe los siguientes en tu inbox. Sin spam, cancela cuando quieras.

Discusión

Escrito por Jorge Ochoa. ¿Encontraste un error?

Abrir en GitHub