Módulo 4: Refactoring Multi-File Coordinado
Interface Changes y Propagación de Cambios
Interface Changes y Propagación de Cambios
Descripción de la cápsula
De los cuatro tipos de refactoring que cubre este módulo, interface changes es el más complejo y el más impactante. Cuando cambias la firma de una función — sus parámetros, su return type, o su nombre de método en una clase base — cada caller, implementación, y test que la usa necesita actualizarse. Un cambio en un lugar se propaga a decenas de archivos.
En esta cápsula vas a aprender a ejecutar interface changes con Claude Code como coordinador. La clave es entender la cascada: un cambio en la interfaz fluye hacia abajo (implementaciones) y hacia arriba (callers). Claude Code puede trazar esa cascada completa y hacer las actualizaciones de forma coherente.
Este es el tipo de refactoring donde Claude Code ofrece el máximo valor. Manualmente, rastrear todos los callers de una función que se usa en 20 archivos es tedioso y propenso a errores. Claude Code lo hace completo y consistente.
Qué es un Interface Change
Definición
Un interface change modifica el "contrato" de una función, método, o clase: lo que recibe, lo que retorna, o cómo se llama. Todo código que depende de ese contrato debe adaptarse.
Tipos de interface changes
# Tipo 1: Agregar parámetro
# Antes:
def create_user(name: str, email: str) -> User:
# Después:
def create_user(name: str, email: str, role: str = "user") -> User:
# Impacto: todos los callers pueden seguir funcionando (default value)
# Riesgo: BAJO
# Tipo 2: Cambiar parámetro obligatorio
# Antes:
def create_user(name: str, email: str) -> User:
# Después:
def create_user(user_data: UserCreateRequest) -> User:
# Impacto: TODOS los callers deben cambiar
# Riesgo: ALTO
# Tipo 3: Cambiar return type
# Antes:
def get_user(user_id: int) -> dict:
# Después:
def get_user(user_id: int) -> User:
# Impacto: todos los callers que acceden al resultado deben adaptarse
# Riesgo: MEDIO
# Tipo 4: Cambiar método de clase base
# Antes (en base class):
class BaseRepository:
def find(self, id: int) -> dict:
# Después:
class BaseRepository:
def find_by_id(self, id: int) -> Model:
# Impacto: TODAS las subclases + todos los callers de todas las subclases
# Riesgo: MUY ALTO
Interface Change con Parámetro Nuevo (Bajo Riesgo)
Con default value (no breaking)
# Prompt a Claude Code:
> "Agrega un parámetro 'role' a create_user() en
src/services/user_service.py. El parámetro debe ser
opcional con default value 'user'. Encuentra todos los
callers que deberían pasar un role explícito (como
las rutas de admin) y actualízalos."
# Antes:
def create_user(name: str, email: str) -> User:
user = User(name=name, email=email, role="user")
db.session.add(user)
return user
# Después:
def create_user(name: str, email: str, role: str = "user") -> User:
user = User(name=name, email=email, role=role)
db.session.add(user)
return user
# Callers actualizados:
# src/api/routes/admin.py:
# Antes: create_user(name, email) # siempre creaba con role="user"
# Después: create_user(name, email, role="admin")
Sin default value (breaking change)
# Prompt:
> "Agrega un parámetro obligatorio 'organization_id' a
create_user() en src/services/user_service.py.
Encuentra TODOS los callers y actualízalos para pasar
organization_id. Si un caller no tiene acceso a
organization_id, reporta el problema."
# Claude Code:
# 1. Actualiza la firma de create_user()
# 2. Encuentra 8 callers
# 3. Actualiza 6 que tienen acceso a organization_id
# 4. REPORTA 2 que no lo tienen (necesitan investigación)
Interface Change con Tipo de Parámetro (Medio Riesgo)
De parámetros individuales a objeto
Este es uno de los refactoring más comunes cuando una función crece:
# Antes: 7 parámetros individuales
def create_order(
user_id: int,
items: list,
shipping_address: str,
billing_address: str,
coupon_code: str | None,
payment_method: str,
notes: str | None
) -> Order:
...
# Después: un objeto de request
class OrderCreateRequest(BaseModel):
user_id: int
items: list
shipping_address: str
billing_address: str
coupon_code: str | None = None
payment_method: str
notes: str | None = None
def create_order(request: OrderCreateRequest) -> Order:
...
Ejecutando con Claude Code
# Paso 1: Tests
> "Ejecuta tests de create_order y confirma que pasan"
# Paso 2: Crear el request object
> "Crea un Pydantic model OrderCreateRequest en
src/models/requests.py con los mismos campos que
los parámetros actuales de create_order(). Los campos
nullable deben tener default None."
# Paso 3: Cambiar la firma
> "Cambia la firma de create_order() en order_service.py
para recibir un solo parámetro 'request: OrderCreateRequest'.
Actualiza el body de la función para usar request.field
en lugar de los parámetros directos."
# Paso 4: Propagar a callers
> "Encuentra todos los callers de create_order() y
actualízalos para construir un OrderCreateRequest
antes de llamar. Muestra cada caller antes y después."
# Ejemplo de propagación:
# Antes:
order = create_order(
user_id=user.id,
items=cart.items,
shipping_address=address,
billing_address=billing,
coupon_code=coupon,
payment_method="credit_card",
notes=None
)
# Después:
request = OrderCreateRequest(
user_id=user.id,
items=cart.items,
shipping_address=address,
billing_address=billing,
coupon_code=coupon,
payment_method="credit_card"
)
order = create_order(request)
# Paso 5: Actualizar tests
> "Actualiza todos los tests de create_order para usar
OrderCreateRequest. Ejecuta tests."
Interface Change en Clase Base (Alto Riesgo)
El efecto cascada
Cuando cambias un método en una clase base, TODAS las subclases deben actualizarse, y TODOS los callers de TODAS las subclases también:
# Clase base con 5 subclases:
class BaseRepository:
def find(self, id: int) -> dict: # ← cambiar esto
...
class UserRepository(BaseRepository): # ← actualizar
def find(self, id: int) -> dict:
...
class OrderRepository(BaseRepository): # ← actualizar
def find(self, id: int) -> dict:
...
# + ProductRepository, InvoiceRepository, PaymentRepository
# Y cada subclase es llamada desde múltiples servicios:
# user_service.py: user_repo.find(user_id) # ← actualizar
# order_service.py: order_repo.find(order_id) # ← actualizar
# admin_service.py: user_repo.find(admin_id) # ← actualizar
# ... (potencialmente 20+ callers)
Ejecutando con Claude Code
# Paso 1: Mapear el impacto completo
> "Voy a cambiar el método find() de BaseRepository a
find_by_id(). Antes de hacer cualquier cambio, mapea
el impacto completo:
1. Todas las subclases de BaseRepository
2. Todos los archivos que llaman .find() en cualquier
subclase de BaseRepository
3. Todos los tests que testean .find()
Reporta el número total de cambios necesarios."
# Output esperado:
# Subclases: 5 (User, Order, Product, Invoice, Payment)
# Callers: 18 archivos con 23 llamadas a .find()
# Tests: 12 tests en 5 archivos
# Total: 40 cambios en 23 archivos
# Paso 2: Tests
> "Ejecuta todos los tests y confirma que pasan"
# Paso 3: Cambio en la base + subclases
> "Renombra find() a find_by_id() en BaseRepository
y en TODAS sus 5 subclasses. No actualices callers todavía."
# Paso 4: Actualizar callers
> "Actualiza todos los 23 callers que llaman .find()
en cualquier repository a .find_by_id(). Muestra
cada cambio."
# Paso 5: Actualizar tests
> "Actualiza los 12 tests que testean .find() a
.find_by_id(). Ejecuta todos los tests."
Interface Change en Return Type
De dict a object
# Antes: retorna dict (sin type safety)
def get_user(user_id: int) -> dict:
row = db.execute("SELECT * FROM users WHERE id = ?", user_id)
return dict(row) # {"id": 1, "name": "Ana", "email": "ana@..."}
# Después: retorna User object (con type safety)
def get_user(user_id: int) -> User:
row = db.execute("SELECT * FROM users WHERE id = ?", user_id)
return User(**dict(row))
# Impacto en callers:
# Antes: user["name"]
# Después: user.name
# Prompt:
> "Cambia get_user() en user_repository.py para retornar
un objeto User en vez de un dict. Encuentra todos los
callers que acceden al resultado como dict (user['name'])
y actualízalos a attribute access (user.name). Ejecuta tests."
Comparación: Interface Change Manual vs Claude Code
| Criterio | Manual | Claude Code |
|---|---|---|
| Mapear impacto | grep + lectura manual | Completo, incluye herencia |
| Encontrar callers | grep por nombre de función | Semántico (distingue overloads) |
| Propagación a subclases | Recordar cada subclase | Automático |
| Actualizar tests | Buscar manualmente | Incluido en la propagación |
| Consistencia | Depende de la atención | Todos los callers se actualizan igual |
| Riesgo de olvidar uno | Alto (en proyectos grandes) | Bajo |
Conexión con Proyecto
En el Proyecto del Módulo (cápsula 06), si el codebase tiene interfaces inconsistentes (funciones con 8 parámetros, return types diferentes para la misma operación, métodos de clase base desactualizados), interface changes es la técnica que necesitas.
Troubleshooting
Problema 1: Caller no puede construir el nuevo tipo
Causa: Un caller no tiene acceso a los datos necesarios para el nuevo parámetro.
Solución: Traza de dónde viene cada dato:
> "El caller en api/routes/admin.py no tiene acceso a
organization_id. ¿De dónde puede obtenerlo? ¿Del
request, del JWT token, o de otra fuente?"
Problema 2: Subclase tiene firma diferente
Causa: Una subclase override tiene parámetros extra.
Solución: Verifica compatibilidad:
> "Al cambiar find() a find_by_id() en BaseRepository,
verifica que ninguna subclase tenga un override de
find() con parámetros adicionales que se perderían."
Problema 3: Return type change rompe serialización
Causa: Los callers serializan el resultado a JSON.
Solución: Asegura que el nuevo type es serializable:
> "Después de cambiar get_user() para retornar User object,
verifica que los callers que hacen json.dumps() o
jsonify() del resultado sigan funcionando. Si User
no es serializable, agrega un método to_dict()."
Problema 4: Demasiados callers para cambiar de golpe
Causa: La función se usa en 30+ lugares.
Solución: Usa el strangler pattern (preview del Módulo 5):
> "Crea una nueva función create_user_v2() con la nueva
firma. Migra callers uno por uno a la nueva versión.
Cuando todos los callers usen v2, elimina la original
y renombra v2 a create_user."
Ejercicios
Ejercicio 1: Clasificar riesgo de interface changes (Fácil)
Clasifica cada cambio como bajo, medio, o alto riesgo:
- Agregar parámetro
verbose: bool = Falsea una función - Cambiar
process(data: dict)aprocess(data: ProcessRequest) - Renombrar método
save()apersist()en clase base con 8 subclases - Cambiar return type de
listaGenerator
Ver solución
- Bajo — default value, no breaking change
- Alto — todos los callers deben construir ProcessRequest
- Muy alto — 8 subclases + todos sus callers
- Medio — callers que indexan (
result[0]) se rompen, los que iteran (for x in result) funcionan
Ejercicio 2: Diseñar propagación (Medio)
send_notification(user_id, message) va a cambiar a send_notification(notification: Notification). Escribe los pasos para Claude Code.
Ver solución
# 1. Crear el modelo
> "Crea un modelo Notification con campos: user_id (int),
message (str), channel (str, default 'email'),
priority (str, default 'normal')."
# 2. Tests
> "Ejecuta tests de send_notification, confirma que pasan."
# 3. Cambiar firma
> "Cambia send_notification() para recibir Notification
en vez de user_id + message. Actualiza el body."
# 4. Propagar
> "Encuentra todos los callers de send_notification().
Para cada uno, construye un Notification object con
los datos existentes. Muestra cada cambio."
# 5. Verificar
> "Ejecuta tests. Si fallan, muestra qué cambió."
Ejercicio 3: Interface change en clase base (Difícil)
BaseService.execute(data: dict) -> dict cambia a BaseService.execute(request: BaseRequest) -> BaseResponse. Hay 6 subclases. Diseña el plan completo.
Ver solución
# 1. Mapear impacto
> "Mapea todas las subclases de BaseService y todos
los callers de .execute() en cada subclase."
# 2. Crear types
> "Crea BaseRequest y BaseResponse como clases base.
Para cada subclase, crea Request/Response específicos:
UserRequest(BaseRequest), UserResponse(BaseResponse), etc."
# 3. Tests
> "Ejecuta todos los tests, confirma que pasan."
# 4. Cambiar base + subclases (una a la vez)
> "Cambia execute() en BaseService para usar BaseRequest/BaseResponse.
Luego actualiza UserService.execute() para usar
UserRequest/UserResponse. Ejecuta tests de UserService."
# Repetir para cada subclase:
> "Actualiza OrderService.execute(). Ejecuta tests."
> "Actualiza PaymentService.execute(). Ejecuta tests."
# ... (6 iteraciones, tests en cada una)
# 5. Actualizar callers
> "Actualiza todos los callers para construir el
Request específico. Ejecuta todos los tests."
Clave: una subclase a la vez con tests entre cada cambio. Nunca 6 subclases de golpe.
Resumen
En esta cápsula aprendiste:
- Interface changes modifican el contrato de una función y propagan a callers e implementaciones
- 4 tipos: agregar parámetro, cambiar tipo de parámetro, cambiar return type, cambiar método de clase base
- El riesgo escala con el número de callers e implementaciones afectadas
- Claude Code mapea el impacto completo incluyendo herencia y callers transitivos
- La cascada es: base → subclases → callers → tests — en ese orden
- Para cambios masivos, usa el strangler pattern: nueva versión + migración gradual
Próxima cápsula: Tests de Regresión para Refactoring. El framework completo de testing que garantiza que cada refactoring preserva comportamiento.
Recursos Adicionales
- Refactoring Guru - Change Method Signature - Explicación visual
- Liskov Substitution Principle - El principio que interface changes deben respetar
- Python Type Hints (PEP 484) - Type hints que documentan interfaces
- Pydantic - Data Validation - Para crear request/response objects tipados
- Python Protocol Classes (PEP 544) - Interfaces implícitas en Python
- Strangler Fig Pattern - Patrón para migración gradual de interfaces