Cargo workspaces: gestionar monorepos en Rust sin complicaciones

Cuando un proyecto en Rust crece, llega un momento en que meterlo todo en un solo crate se vuelve incómodo. La lógica de negocio, la capa de API, el cliente CLI, las macros procedurales... cada pieza tiene su propio ciclo de vida y sus propias dependencias. La solución no es crear cinco repositorios separados: es usar un workspace de Cargo.

Por qué un workspace y no varios proyectos separados

En Rust, el crate es la unidad de compilación. Un proyecto con cierta complejidad suele tener varios: la librería con la lógica pura, el binario que la usa, los tests de integración que no quieres mezclar con los unitarios, y quizás alguna macro proc-macro. Mantener esos crates como proyectos independientes funciona, pero tiene un coste claro.

Sin workspace, cada crate tiene su propio Cargo.lock y sus propias dependencias compiladas. Si dos crates usan serde, Cargo puede compilar dos versiones distintas. Si actualizas una dependencia en uno, el otro se queda atrás y a veces ni te enteras. La caché de compilación no se comparte, así que compilas el mismo código dos o tres veces.

Con un workspace, hay un solo Cargo.lock en la raíz, una sola carpeta target/ compartida y versiones de dependencias sincronizadas entre todos los crates miembro. Compilas una vez y todos los crates aprovechan el resultado. Para proyectos medianos y grandes, la diferencia de tiempo de compilación es notable.

Estructura de un workspace

Un workspace es sorprendentemente sencillo de configurar. Creas un Cargo.toml en la raíz del repositorio y añades la sección [workspace] con la lista de miembros:

[workspace]
members = ["crates/core", "crates/api", "crates/cli"]
resolver = "2"

Cada miembro tiene su propio Cargo.toml con sus dependencias y su configuración particular. Lo que no tiene es su propio Cargo.lock ni su propia carpeta target/: eso vive en la raíz y lo comparten todos.

El resolver = "2" es la versión del resolver de dependencias de Cargo. Desde Rust 2021 es el valor por defecto en proyectos nuevos, pero en workspaces conviene declararlo explícitamente para evitar comportamientos distintos según la edición de cada crate miembro.

workspace.dependencies: una versión para todos

Uno de los problemas clásicos en proyectos multi-crate es que cada equipo o cada parte del código va actualizando dependencias a su ritmo. De repente, crates/api usa serde = "1.0.190" y crates/core usa serde = "1.0.195". Cargo acaba compilando ambas versiones. No es un error, pero es ineficiente y puede causar incompatibilidades sutiles entre tipos.

La solución está en [workspace.dependencies]. Declaras las dependencias compartidas en el Cargo.toml de la raíz:

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"

Y en cada crate miembro que necesite esa dependencia, simplemente pones:

[dependencies]
serde = { workspace = true }
tokio = { workspace = true }

Con workspace = true, el crate hereda exactamente la versión y los features que definiste en la raíz. Si algún crate necesita features adicionales, puede añadirlos sin sobrescribir la definición del workspace:

[dependencies]
tokio = { workspace = true, features = ["test-util"] }

Esto garantiza que todos los crates usan la misma versión base y simplifica mucho las actualizaciones: cambias la versión en un solo sitio y se propaga a todos.

workspace.package: metadatos compartidos

Lo mismo que con las dependencias aplica a los metadatos del proyecto. Si tienes diez crates y quieres que todos lleven la misma versión, la misma licencia y el mismo autor, sin [workspace.package] tendrías que editar diez archivos cada vez que publicas una nueva versión.

[workspace.package]
version = "0.3.0"
authors = ["Tu Nombre "]
edition = "2021"
license = "MIT"
repository = "https://github.com/tu-usuario/tu-proyecto"

En cada crate miembro, heredas lo que necesitas:

[package]
name = "mi-crate-core"
version.workspace = true
edition.workspace = true
license.workspace = true

Los crates que tengan una versión propia (por ejemplo, si publicas en crates.io con versiones distintas según el crate) pueden sobrescribir el campo sin ningún problema. La herencia no es obligatoria: es opt-in campo por campo.

Trabajar con un workspace desde la CLI

Cargo tiene soporte nativo para workspaces y la mayoría de comandos funcionan igual que con un proyecto normal. Desde la raíz del workspace:

  • cargo build: compila todos los crates del workspace.
  • cargo build -p api: compila solo el crate api.
  • cargo test -p core: ejecuta los tests del crate core.
  • cargo run -p cli -- --help: ejecuta el binario cli con el argumento --help.
  • cargo clippy --all: linting en todos los crates a la vez.
  • cargo fmt --all: formatea todos los crates.

El flag -p (abreviatura de --package) te permite operar sobre un crate concreto sin salir del directorio raíz. En proyectos grandes es lo que más usarás en el día a día: compilar o testear solo la parte en la que estás trabajando.

Dependencias entre crates del mismo workspace

Cuando un crate del workspace necesita usar código de otro, la referencia se hace por ruta. En el Cargo.toml de crates/api:

[dependencies]
core = { path = "../core" }

Cargo entiende que es un crate del mismo workspace y no lo trata como una dependencia externa. El graph de dependencias se valida en tiempo de compilación, así que los ciclos entre crates dan error antes de que puedas ejecutar nada.

Lo más útil aquí es la detección incremental de cambios. Si modificas algo en crates/core y luego ejecutas cargo build -p api, Cargo detecta que core ha cambiado y recompila solo lo necesario. No tienes que hacer nada especial: funciona igual que con dependencias externas pero mucho más rápido porque el código está en local.

Cuándo tiene sentido separar en crates

No todo proyecto merece un workspace desde el primer día. Pero hay situaciones donde la separación en crates dentro de un workspace tiene sentido claro:

Librería y binario

Separar crates/lib (lógica pura, sin dependencias de I/O o CLI) de crates/bin (el ejecutable) tiene varias ventajas. La librería se puede publicar en crates.io y reutilizarse en otros proyectos. Los tests unitarios de la lógica no dependen del binario. Y si en el futuro quieres añadir una interfaz web además de la CLI, tienes la base lista.

Macros procedurales

Las macros proc-macro deben estar en un crate separado por diseño de Rust: el compilador las procesa antes que el resto del código y necesitan compilarse como un crate especial (proc-macro = true). Si usas macros propias, no tienes opción, el workspace te lo pone fácil.

Tests de integración pesados

Los tests de integración que levantan bases de datos, hacen peticiones HTTP o tienen dependencias grandes pueden ralentizar mucho los tests unitarios del core. Moverlos a un crate propio dentro del workspace te permite ejecutarlos por separado con cargo test -p integration-tests.

Múltiples binarios con base compartida

Si tu proyecto tiene un CLI, un daemon y una herramienta de migración de datos, los tres pueden vivir en crates separados dentro del workspace y compartir el mismo crates/core. Compilas todos de una vez, pero despliegas cada binario por su cuenta.

Publicar crates de un workspace en crates.io

Publicar en crates.io desde un workspace requiere un poco de atención al orden y a las dependencias entre crates. Si api depende de core, tienes que publicar core primero:

cargo publish -p core
cargo publish -p api

Hay un detalle importante: las dependencias entre crates del workspace usan path para referencias locales, pero crates.io no puede resolver rutas locales. Antes de publicar, tienes que añadir también la versión de crates.io junto con el path:

[dependencies]
core = { path = "../core", version = "0.3.0" }

Cargo usa el path cuando compila en local y la version cuando resuelve desde crates.io. Así funciona en ambos contextos sin cambiar nada.

Si gestionas workspaces grandes con varios crates publicables, la herramienta cargo-workspaces (un crate externo) automatiza el proceso de publicación ordenada, actualización de versiones y generación de changelogs. No es parte de Cargo oficial, pero es una de esas herramientas que se adoptan rápido en proyectos serios.

Un workspace es estructura, no complejidad

La idea central es simple: un repositorio, varios crates, todo coordinado desde el Cargo.toml de la raíz. Si ya conoces Cargo para proyectos simples, el salto a workspaces es pequeño. La mayor parte del tiempo ni lo notas: ejecutas los mismos comandos, solo añades -p nombre-del-crate cuando quieres operar sobre uno en concreto.

Para profundizar más, puedes leer sobre Rust 1.95 y las mejoras en Cargo o echar un vistazo a por qué Rust es popular en proyectos grandes, donde el uso de workspaces es la norma.

Imagen: Pexels / Daniil Komov

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP