---
name: proceso-modular-split
description: >
  Playbook validado a escala (~20,000 LOC, 2 módulos refactorizados en SIGM:
  recaudacion ADR-0016 + catastro ADR-0017) para dividir un módulo monolítico
  en sub-paquetes coherentes preservando la API pública. Usa compositor por
  herencia múltiple (NO __getattr__), helpers transversales en clase base,
  router compositor zero-LOC con APP_ROUTER raíz. Cubre las 8 sesiones del
  playbook (S1-S8), criterios para decidir cuándo aplicar el split, y los
  anti-patrones detectados por auditoría humana que las auditorías técnicas
  NO detectan. Cargar cuando el módulo backend supera ~1500 LOC en un solo
  archivo (router.py, service.py o repository.py), cuando hay solicitud
  explícita de "refactorizar X aplicando ADR-0016/0017", o cuando el módulo
  monolítico se vuelve unmaintainable por acoplamiento entre sub-dominios.
version: "1.0.0"
herramientasPermitidas: [Read, Grep, Glob]
exclusiones:
  - "No cargar para módulos pequeños (<800 LOC en su archivo más grande) — el split agrega overhead arquitectural sin beneficio."
  - "No cargar para frameworks no-Python — el patrón compositor por herencia múltiple es específico de la flexibilidad de MRO de Python; en Java/C# se hace con interfaces y composition diferente."
  - "No cargar para refactor de capa de datos (BD schema split, sharding) — este playbook es para split de código aplicación; BD split requiere migración con expand-contract distinta."
  - "No cargar para split de microservicios (separar en servicios HTTP distintos) — este playbook mantiene un solo proceso/deployment; para microservicios cargar `microservicios`."
evolvable: true
---

# Proceso Modular Split (Playbook ADR-0016/0017)

Split de módulo monolítico en sub-paquetes preservando API pública. Validado a escala 20k LOC.

## Cuándo NO cargar

- Módulo pequeño (<800 LOC en su archivo más grande) — overhead arquitectural sin beneficio.
- Framework no-Python (Java, Go, Rust) — el patrón compositor por herencia múltiple aprovecha MRO de Python.
- Refactor de capa de datos (BD schema, sharding) — requiere expand-contract distinto.
- Split de microservicios HTTP — cargar `microservicios`; este playbook mantiene un solo proceso.

## Criterio para aplicar

Aplicar el playbook cuando AL MENOS DOS se cumplen:

1. `router.py` (FastAPI) o equivalente supera **~1500 LOC** con ~30+ endpoints heterogéneos.
2. `service.py` supera **~1700 LOC** con sub-dominios identificables (e.g., en `catastro/`: predios, valuación, vialidades, manzanas, construcciones, inspecciones, operaciones, factores, geo).
3. `repository.py` supera **~2400 LOC** o tiene métodos de tablas que NO se cruzan entre sí.
4. Tests del módulo tardan >60s o requieren imports cross-dominio frágiles.
5. Cambio en una "sección" del módulo rompe tests de otra sección que no debería estar relacionada (signal de acoplamiento implícito).

Si solo se cumple 1 criterio, probablemente lo correcto es extraer una sub-función o helper, NO aplicar el split completo.

## El patrón: compositor por herencia múltiple (NO `__getattr__`)

### Lo que NO hacer

```python
# MAL — compositor por __getattr__: invisible al type checker, frágil
class CatastroService:
    def __init__(self, conn):
        self._predios = CatastroPrediosService(conn)
        self._valuacion = CatastroValuacionService(conn)
        ...

    def __getattr__(self, name):
        # Mágico, pero el type checker no ve los métodos delegados.
        for sub in (self._predios, self._valuacion, ...):
            if hasattr(sub, name):
                return getattr(sub, name)
        raise AttributeError(name)
```

Problemas:
- IDE no autocompleta los métodos delegados.
- `mypy`/`pyright` no detecta llamadas a métodos inexistentes.
- Stack traces opacos (la línea de error apunta al `__getattr__`, no al sitio real).
- Imposible inspeccionar el MRO efectivo.

### Lo que SÍ hacer

```python
# BIEN — herencia múltiple: MRO inspeccionable, type checker feliz
class CatastroPrediosService(CatastroServiceBase):
    async def crear_predio(self, ...): ...
    async def obtener_predio(self, ...): ...

class CatastroValuacionService(CatastroServiceBase):
    async def crear_valuacion(self, ...): ...
    async def listar_vigentes(self, ...): ...

class CatastroVialidadesService(CatastroServiceBase):
    async def listar_vialidades(self, ...): ...

# El compositor hereda de TODOS los sub-services.
class CatastroService(
    CatastroPrediosService,
    CatastroValuacionService,
    CatastroVialidadesService,
    # ... 9 sub-services
):
    """Compositor monolítico transitorio.

    Mantiene la API pública del módulo monolítico previo. Los routers de la
    fase posterior consumen sub-services específicos directamente — el
    compositor queda como fallback de retrocompatibilidad.
    """
    pass
```

Verificación inspeccionable:
```python
>>> CatastroService.__mro__
(CatastroService, CatastroPrediosService, CatastroValuacionService,
 CatastroVialidadesService, ..., CatastroServiceBase, object)
```

El type checker ve todos los métodos. El IDE autocompleta. El stack trace apunta al método real, no al compositor.

### Helpers transversales en clase base

Cuando varios sub-services necesitan el mismo helper (ej. `_validar_predio_existe`), va en la clase base (NO se duplica entre sub-services):

```python
class CatastroServiceBase:
    """Helpers compartidos por todos los sub-services de catastro."""

    def __init__(self, conn):
        self._conn = conn

    async def _validar_predio_existe(self, predio_id: uuid.UUID) -> None:
        row = await self._conn.fetchrow(
            "SELECT 1 FROM catastro.predio WHERE id=$1",
            predio_id,
        )
        if row is None:
            raise NotFoundError(
                message=f"Predio {predio_id} no encontrado",
                code="PREDIO_NO_ENCONTRADO",
            )

    async def _tenant_actual(self) -> uuid.UUID:
        # Lee del contexto async (contextvars), no del estado de instancia.
        ...
```

Regla: helper que ≥80% de sub-services necesitan → clase base. Si solo lo necesitan 1-2 sub-services, vive en uno de ellos y los otros lo importan via composición (no herencia).

## El playbook S1-S8

### S1 — ADR + PLAN.md + consolidación previa

- Crear `.planning/adrs/NNNN-split-modulo-X.md` con:
  - Motivación cuantitativa (LOC actuales, problemas concretos detectados).
  - Lista de sub-dominios identificados con criterio de separación.
  - Plan de migración (no hay big-bang — son 8 sesiones secuenciales).
- Consolidar primero archivos relacionados que ya están separados pero deberían convivir (ej. en catastro: `repository_geo.py` + `router_geo.py` → `geo/`).

### S2 — Split `repository.py` en sub-repositorios

- Crear `<modulo>/<sub>/repository.py` por cada sub-dominio.
- Mover métodos del repository monolítico a su sub-repositorio.
- El `<modulo>/repository.py` original queda como compositor por herencia múltiple (mismo patrón que el service).

### S3 — Split `router.py` en sub-routers + APP_ROUTER raíz

- Crear `<modulo>/<sub>/router.py` con sub-APIRouter.
- Crear `<modulo>/router.py` reducido (~38 LOC) que solo agrega APP_ROUTER raíz e incluye los sub-routers:
  ```python
  from fastapi import APIRouter
  from .predios.router import router as predios_router
  from .valuacion.router import router as valuacion_router
  # ...

  app_router = APIRouter(prefix="/catastro", tags=["catastro"])
  app_router.include_router(predios_router, prefix="/predios")
  app_router.include_router(valuacion_router, prefix="/valuacion")
  # ...
  ```

### S4 — Split `service.py` en sub-services + `service_base.py`

- Aplicar el patrón "compositor por herencia múltiple" descrito arriba.
- Mover métodos del service monolítico a su sub-service.
- Helpers transversales → `service_base.py`.

### S5 — Schemas: re-export por sub-paquete

- Schemas Pydantic NO se parten — siguen viviendo en `<modulo>/schemas.py` (un solo archivo) O se mueven a `<modulo>/<sub>/schemas.py` pero NO se duplica definición.
- Si se mueve, hacer re-export en `<modulo>/schemas.py` para retrocompatibilidad de imports:
  ```python
  from .predios.schemas import PredioCreate, PredioResponse  # noqa: F401
  from .valuacion.schemas import ValuacionCreate, ValuacionResponse  # noqa: F401
  ```

### S6 — Reorganización de tests

- Mover `tests/<modulo>/test_*.py` a `tests/<modulo>/<sub>/test_*.py` con `git mv` (preserva historial).
- Conftest.py por sub-dominio si los fixtures divergen.

### S7 — Auditoría nemesis por sub-router + arch-cleanup

- `/swl:nemesis --modulo <modulo>/<sub>/router.py --remediar` por cada sub-router.
- Documentar hallazgos por sub-dominio.
- **S7-arch-cleanup**: migrar routers que aún consumen el compositor a su sub-service específico:
  ```python
  # MAL — router consume compositor cuando ya existe sub-service
  from app.catastro.service import CatastroService
  servicio = CatastroService(conn)
  predio = await servicio.obtener_predio(id)

  # BIEN — router consume el sub-service directamente
  from app.catastro.predios.service import CatastroPrediosService
  servicio = CatastroPrediosService(conn)
  predio = await servicio.obtener_predio(id)
  ```
  El compositor queda como fallback para código legacy, no como ruta normal.

### S8 — Cierre formal + actualización docs

- Actualizar `CLAUDE.md` del proyecto con los anti-patrones detectados en S7-arch-cleanup.
- Actualizar `AGENTS.md` con la nueva estructura del módulo.
- Marcar la fase como cerrada en `.planning/HOJA-RUTA.md`.

## Anti-patrones detectados que las auditorías NO detectan (S7-arch-cleanup)

La auditoría técnica (`revisor-codigo-swl`, `nemesis-auditor-swl`) NO detectó estos en SIGM 2026-05-20 — los detectó el usuario al revisar el código post-refactor:

1. **Router importa el compositor cuando existe sub-service específico**: defeating-purpose del refactor. Detectable con `grep -rn "from app.<modulo>.service import <Modulo>Service" app/<modulo>/<sub>/router.py` — debería retornar vacío después del split.

2. **Router accede a `servicio._repo` o métodos privados**: rompe encapsulación. Detectable con `grep -rn "servicio\._repo\|servicio\._mapear_" app/<modulo>/<sub>/router.py`. Fix: encapsular en método público del sub-service.

3. **Default `None` pasado explícitamente bypasea el default del callee**: `await servicio.listar_vialidades(activa=None)` cuando el contrato dice "omitir devuelve solo activas". Fix: en el router, `activa=True if activa is None else activa` o cambiar firma del callee a `activa: bool = True` (no `bool | None`).

4. **CLAUDE.md y AGENTS.md desincronizados**: tras el split, ambos archivos describen el módulo. Si solo se actualiza uno, los agentes que cargan AGENTS.md desconocen el cambio. Fix: gate en CI que compara secciones del módulo entre los dos archivos.

## Reglas no-negociables

- **NUNCA `__getattr__`** para delegación entre sub-services. El compositor es herencia múltiple inspeccionable.
- **NUNCA partir schemas Pydantic sin re-export** — rompe imports existentes y obliga a refactor cascada.
- **NUNCA hacer big-bang** — el playbook ES 8 sesiones secuenciales. Saltarse S5 o S6 deja el módulo en estado inconsistente.
- **SIEMPRE `git mv` para mover archivos** — preserva blame y permite ver el historial post-split.
- **SIEMPRE actualizar CLAUDE.md + AGENTS.md en S8** — desincronizados generan deuda invisible que aflora en la siguiente fase.
- **SIEMPRE S7-arch-cleanup después de los fixes nemesis** — sin esa pasada, los routers mantienen acoplamiento al compositor que el split intentaba romper.

## Gotchas / Errores comunes no obvios

- **MRO con conflict en herencia múltiple**: si dos sub-services definen método con el mismo nombre, Python resuelve por MRO (orden de declaración en la clase compositor). Si el conflict es accidental (dos sub-dominios distintos con método homónimo), el MRO oculta uno. Fix: tests de regresión sobre cada método público del compositor, no solo de los sub-services.
- **Sub-service que requiere estado de otro sub-service en su `__init__`**: ej. `CatastroValuacionService` necesita `CatastroPrediosService` para validar el predio antes de crear valuación. Si el constructor lo recibe, el compositor por herencia múltiple no puede instanciar (`__init__` debe ser igual en todos). Fix: los sub-services NO se referencian mutuamente; comparten helpers en la clase base, no instancias entre sí.
- **Tests de integración que importan el compositor pasan, tests unitarios del sub-service fallan**: el sub-service requiere fixtures que el compositor ya configura. Fix: cada sub-service tiene su `conftest.py` con fixtures locales (mock de `_conn`, helpers de creación de datos del sub-dominio).
- **Migración del git history con `git mv` no preserva blame si hay cambios en el mismo commit**: si haces `git mv router.py predios/router.py` y modificas el contenido en el mismo commit, `git blame --follow` puede fallar en versiones antiguas de git. Fix: hacer `git mv` puro en un commit, modificar contenido en commit separado.
- **El compositor monolítico es "transitorio" pero queda 6+ meses**: si el equipo no migra los consumers al sub-service específico, el compositor se vuelve permanente. Fix: agregar deprecation warning al `__init__` del compositor: `warnings.warn("Use <sub>Service directly", DeprecationWarning, stacklevel=2)`.

## Referencias

| Tema | Recurso |
|------|---------|
| ADR-0016 (recaudación split) | SIGM `.planning/adrs/0016-split-modulo-recaudacion.md` |
| ADR-0017 (catastro split) | SIGM `.planning/adrs/0017-split-modulo-catastro.md` |
| Patrón de error design (kwargs explícitos) | `Skill("backend-error-design")` |
| Testing con transaction mock | `Skill("backend-async-postgres-testing")` |
| Arquitectura general (módulos profundos, DRY) | `~/.claude/rules/arquitectura.md` |
