Qué es un monorepo por features en Flutter

Un monorepo por features es un único repositorio con varios paquetes Dart: la app (el runner, fino), un paquete por cada feature del producto (autenticación, checkout, perfil) y algunos paquetes compartidos de bajo nivel (red, diseño, utilidades). Cada feature es un paquete publicable en local con su propio pubspec.yaml, sus tests y sus dependencias declaradas.

No es lo mismo que la arquitectura limpia. La arquitectura limpia ordena las capas dentro de un paquete: presentación, dominio y datos. El monorepo por features ordena los límites entre paquetes: qué puede importar qué. Son decisiones independientes y lo normal es combinarlas, cada feature con sus tres capas dentro.

La diferencia práctica está en el compilador. Con todo en lib/, nada te impide que la pantalla de checkout importe un detalle interno de perfil. Con paquetes, si no lo declaras como dependencia en el pubspec.yaml, no compila. El límite deja de ser una norma de equipo y pasa a ser una regla que la máquina comprueba.

Dos capas que se confunden a menudo: pub workspaces y melos

Durante años melos lo hacía todo en los monorepos Dart: resolver dependencias, enlazar paquetes y ejecutar scripts. Desde Dart 3.6 eso cambió, y conviene tenerlo claro antes de montar nada porque son dos herramientas con trabajos distintos.

Pub workspaces es parte del SDK de Dart y se encarga de la resolución. Declaras los paquetes miembros en el pubspec.yaml raíz, cada paquete se apunta con resolution: workspace, y obtienes un único pubspec.lock compartido en la raíz. Eso elimina los conflictos de versiones entre paquetes, que era el dolor clásico del monorepo. Como extra, dart analyze pasa por todo el workspace de una vez.

Melos queda como capa de orquestación por encima. Ejecuta un comando en todos los paquetes a la vez (melos run test), gestiona versionado y changelogs cuando publicas, y lanza scripts del ciclo de vida. Su melos bootstrap instala dependencias y corre scripts de arranque; en cambio el antiguo melos analyze desapareció, porque dart analyze ya cubre el workspace entero.

pub workspacesmelos
De dónde vieneSDK de Dart (3.9+ recomendado)paquete externo (8.x)
Qué resuelvedependencias y lockfile únicocomandos y publicación entre paquetes
Lo usas paraque todo compile con versiones coherentescorrer tests/scripts en todos los paquetes, versionar
Lo necesitas siempresí, en cuanto hay más de un paqueteno, depende del tamaño

El matiz importa: pub workspaces es casi obligatorio desde el segundo paquete. Melos es opcional, y para un monorepo pequeño puedes vivir sin él con un par de scripts.

Cómo queda la estructura

Un árbol mínimo:

mi_app/
  pubspec.yaml            # raíz: declara el workspace, no es un paquete de app
  app/                    # el runner, fino: navegación y composición
    pubspec.yaml
  features/
    auth/
      pubspec.yaml
      lib/
      test/
    checkout/
      pubspec.yaml
  packages/
    core_network/
      pubspec.yaml
    design_system/
      pubspec.yaml

El pubspec.yaml raíz declara los miembros:

name: mi_app_workspace
environment:
  sdk: ^3.9.0
workspace:
  - app
  - features/auth
  - features/checkout
  - packages/core_network
  - packages/design_system

Y cada paquete se suma al workspace con una línea:

name: auth
environment:
  sdk: ^3.9.0
resolution: workspace
dependencies:
  core_network:
  design_system:

Con esto, dart pub get desde la raíz resuelve todo contra un único pubspec.lock. La feature checkout depende de auth solo si lo declara; si no, el import falla al compilar.

Por qué esto importa ahora que un agente escribe código

Hace tres años el argumento para partir un monorepo era de equipo: builds más rápidas en CI, ownership claro, menos conflictos de merge. Sigue siendo cierto. Pero hay un motivo nuevo que pesa, y es cómo trabajan los agentes de IA que generan código.

Un agente al que le pides una tarea en un lib/ monolítico tiene todo el proyecto a mano. Lee lo que quiere, edita lo que cree que toca y sus cambios pueden alcanzar cualquier rincón. Cuando la tarea está acotada a un paquete, el radio de daño también lo está. El agente ejecuta los tests de esa feature en segundos en vez de esperar a la suite completa, el pull request queda pequeño y revisable, y lo que no está declarado como dependencia sencillamente no compila. La frontera que te protege de un compañero despistado te protege igual del agente.

Esto no sustituye al aislamiento de seguridad: sandboxear el proceso del agente es otra capa, aparte. Lo que da es contención de alcance dentro del código, y encaja con lo que ya haces para que el repo sea legible por una máquina: tests rápidos por paquete, límites explícitos, nombres que dicen qué hace cada cosa.

Cuándo NO partir

Partir tiene coste, así que la pregunta útil no es "¿monorepo sí o no?" sino "¿ya me duele lo que esto resuelve?".

No lo hagas si tu app es pequeña o joven. Una app de una o dos pantallas, con una persona tocándola, no tiene el problema que esto arregla: le añades la fricción de los límites sin cobrar el beneficio. Un lib/ con carpetas por feature te lleva muy lejos antes de necesitar paquetes de verdad.

No lo hagas todo de golpe. La migración desde un monolito va paquete a paquete, empezando por lo compartido de bajo nivel (red, diseño), no por la feature más enredada. Si intentas partir las diez features en una semana, pasarás más tiempo peleando con dependencias circulares que entregando.

Y no metas melos antes de necesitarlo. Si tienes tres o cuatro paquetes y un pipeline sencillo, pub workspaces más un par de scripts en el Makefile o en GitHub Actions cubren el día a día. Melos entra cuando ya duele correr comandos paquete a paquete o cuando publicas paquetes versionados.

El coste real de mantener los límites

El monorepo no se rompe el día que lo montas, sino seis meses después, cuando los límites se relajan. Tres cosas que vemos repetirse en apps que mantenemos:

Un paquete shared que se convierte en cajón de sastre. Todo lo que no sabes dónde poner acaba ahí, y en un año media app depende de él. En ese punto has recreado el monolito con pasos extra. Divide lo compartido por responsabilidad (core_network, design_system) y sé estricto con qué entra.

Dependencias circulares entre features. Si checkout necesita algo de auth y auth necesita algo de checkout, el diseño está mal: eso compartido sube a un paquete de bajo nivel del que ambos dependen. El compilador te avisará, pero la tentación de forzarlo estará.

Refactors que cruzan paquetes. Renombrar algo que usan cinco features toca cinco pubspec.yaml y cinco suites de tests. Es más trabajo que en un monolito, y es el precio de la frontera. A cambio, cada cambio es explícito y nada se cuela sin querer.

Súmale la configuración de CI/CD, que gana valor aquí: un pipeline que detecta qué paquetes cambiaron y solo testea esos es donde el monorepo devuelve el tiempo invertido.

Por dónde empezar

Un orden que funciona, sin romper producción:

  1. Añade el pubspec.yaml raíz con el workspace y mueve la app actual a app/. Comprueba que compila y pasan los tests antes de tocar nada más.
  2. Extrae primero un paquete compartido sin lógica de negocio, el de diseño o el de red. Es el de menor riesgo y valida el montaje.
  3. Saca la primera feature de verdad, con sus tests dentro. Declara de qué paquetes depende.
  4. Repite feature a feature, en pull requests pequeños. No hay prisa; un monorepo a medias funciona.
  5. Cuando correr comandos a mano canse, añade melos para los scripts transversales y, si publicas paquetes, para el versionado.

Partir un monorepo es una decisión de arquitectura con fecha de caducidad en la cabeza: hazlo cuando el monolito ya te frena, no antes. Si lo que tienes es una app grande, con varias personas y agentes tocándola a diario, los límites entre paquetes dejan de ser teoría y pasan a ahorrarte horas cada semana.

En Dribba montamos y migramos este tipo de estructura en apps Flutter en producción, con equipo senior in-house. Si estás en el punto de decidir si tu app ya pide paquetes o aún no, hablamos.