←

28 paquetes composables sin dependencias circulares: arquitectura de ulfblk

Contexto

ulfblk tiene 28 paquetes (19 Python + 9 TypeScript) que se pueden combinar segun lo que necesites. El peligro en cualquier monorepo con paquetes interrelacionados: dependencias circulares. Si billing importa de scheduling y scheduling importa de billing, no puedes instalar uno sin el otro, los tests se acoplan, y mover un paquete a otro repo se vuelve imposible. Necesitabamos una regla arquitectonica que previniera esto desde el diseno.

Lo que aprendi

La regla es simple: depende de core, no de siblings. Cada paquete solo puede depender de ulfblk-core (el unico obligatorio) y de dependencias externas. Las integraciones entre paquetes se validan con "recetas".

Grafo de dependencias

                    ulfblk-core
                   /     |     \
                  /      |      \
          ulfblk-auth  ulfblk-db  ulfblk-redis
              |          |            |
              |     ulfblk-multitenant|
              |          |            |
        ulfblk-billing   |     ulfblk-gateway
              |          |
        ulfblk-scheduling|
              |          |
        ulfblk-calendar  |
              |          |
        ulfblk-channels  |
                         |
                  ulfblk-ai-rag

  Regla: las flechas solo van hacia abajo.
  Ningun paquete depende de un hermano del mismo nivel.

En TypeScript, la estructura es similar:

                @ulfblk/types
               /      |      \
    @ulfblk/api-client |   @ulfblk/ui
              |        |        |
    @ulfblk/auth-react |   @ulfblk/dashboard
                       |
              @ulfblk/calendar-ui
              @ulfblk/chat-ui
              @ulfblk/forms

Declaracion explicita de dependencias (Python)

Cada paquete declara exactamente de que depende. No hay imports implicitos entre hermanos.

# packages/ulfblk-scheduling/pyproject.toml
[project]
name = "ulfblk-scheduling"
version = "0.3.0"
dependencies = [
    "ulfblk-core",        # Unica dependencia interna
    "sqlalchemy>=2.0",
]

# NO tiene ulfblk-calendar, ulfblk-channels, etc.
# Si necesitas scheduling + calendar, usas una receta.
# packages/ulfblk-billing/pyproject.toml
[project]
name = "ulfblk-billing"
version = "0.3.0"
dependencies = [
    "ulfblk-core",        # Unica dependencia interna
    "stripe>=8.0",
    "httpx>=0.27",
]

# NO depende de ulfblk-auth ni de ulfblk-db directamente.
# La integracion con auth se hace en la app, no en el paquete.

Declaracion explicita de dependencias (TypeScript)

{
  "name": "@ulfblk/calendar-ui",
  "dependencies": {
    "@ulfblk/types": "workspace:*"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

@ulfblk/types es el equivalente de ulfblk-core en TypeScript: el unico paquete del que todos pueden depender. Los paquetes GUI nunca importan de @ulfblk/api-client ni de @ulfblk/auth-react.

Recetas: composicion validada

Las recetas documentan y testean combinaciones especificas de paquetes. Son la forma oficial de decir "estos paquetes funcionan juntos".

# recipes/bot-de-citas/test_integration.py
"""
Receta: Bot de Citas
Paquetes: core + db + scheduling + calendar + channels
Valida que la combinacion funciona end-to-end.
"""
import pytest
from ulfblk_core import create_app
from ulfblk_db import init_db
from ulfblk_scheduling import SlotService
from ulfblk_calendar import CalendarSync
from ulfblk_channels import ChannelRouter


@pytest.fixture
async def app():
    """Crea una app con todos los paquetes de la receta."""
    application = create_app(
        packages=[
            "ulfblk_db",
            "ulfblk_scheduling",
            "ulfblk_calendar",
            "ulfblk_channels",
        ]
    )
    await init_db(application)
    return application


async def test_booking_flow(app):
    """Flujo completo: crear slot, reservar, sincronizar, notificar."""
    slot_service = SlotService()
    calendar_sync = CalendarSync()
    channel_router = ChannelRouter()

    # 1. Crear slot disponible
    slot = await slot_service.create_slot(
        provider_id="provider-1",
        start_utc="2026-03-15T10:00:00Z",
        end_utc="2026-03-15T11:00:00Z",
    )
    assert slot.is_available

    # 2. Reservar
    booked = await slot_service.book(slot.id, user_id="user-1")
    assert not booked.is_available

    # 3. Sync a Google Calendar
    event_id = await calendar_sync.push_to_google(booked)
    assert event_id is not None

    # 4. Notificar por WhatsApp
    msg_id = await channel_router.send(
        channel="whatsapp",
        recipient="+521234567890",
        content=f"Tu cita fue confirmada: {booked.start_utc}",
    )
    assert msg_id is not None

Validacion automatica del grafo

Un script simple que detecta dependencias circulares parseando los pyproject.toml de cada paquete.

"""Detecta dependencias circulares en el monorepo."""
from pathlib import Path
import tomllib


def build_dependency_graph(packages_dir: Path) -> dict[str, list[str]]:
    """Construye el grafo de dependencias internas."""
    graph: dict[str, list[str]] = {}

    for pyproject_path in packages_dir.glob("*/pyproject.toml"):
        with open(pyproject_path, "rb") as f:
            config = tomllib.load(f)

        pkg_name = config["project"]["name"]
        deps = config["project"].get("dependencies", [])

        # Filtrar solo dependencias internas (ulfblk-*)
        internal_deps = [
            d.split(">=")[0].split("==")[0].strip()
            for d in deps
            if d.startswith("ulfblk-")
        ]
        graph[pkg_name] = internal_deps

    return graph


def find_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
    """Detecta ciclos con DFS."""
    cycles: list[list[str]] = []
    visited: set[str] = set()
    path: list[str] = []

    def dfs(node: str) -> None:
        if node in path:
            cycle_start = path.index(node)
            cycles.append(path[cycle_start:] + [node])
            return
        if node in visited:
            return
        path.append(node)
        for dep in graph.get(node, []):
            dfs(dep)
        path.pop()
        visited.add(node)

    for node in graph:
        dfs(node)

    return cycles


packages_dir = Path("packages")
graph = build_dependency_graph(packages_dir)
cycles = find_cycles(graph)

if cycles:
    print(f"FAIL: {len(cycles)} dependencias circulares encontradas:")
    for cycle in cycles:
        print(f"  {' -> '.join(cycle)}")
else:
    print(f"OK: {len(graph)} paquetes, 0 dependencias circulares")

Leccion clave

La regla "depende de core, no de siblings" elimina las dependencias circulares a nivel arquitectonico. No es un linter que detecta ciclos despues de crearlos -- es una restriccion de diseno que los previene. Las integraciones entre paquetes se hacen en la capa de aplicacion (la app que consume los paquetes) o en las recetas, nunca dentro de los paquetes mismos. Esto mantiene cada paquete genuinamente independiente e instalable por separado.

Referencia