Workspaces: monorepos que comparten lockfile
Un workspace agrupa varios crates bajo un único Cargo.lock y un único target. La herencia de dependencias y metadatos con workspace = true elimina la duplicación, y el target compartido acelera la compilación. Cuándo un monorepo ayuda y cuándo obliga a acuerdos que no deberías forzar.
Un workspace no es «una carpeta con varios proyectos»: es un único universo de compilación con estructura interna. Varios crates —una librería core, un binario cli, un servidor web— comparten un solo Cargo.lock, un solo directorio target/ y, con la herencia, una sola fuente de verdad para versiones y metadatos. Esa unidad tiene consecuencias profundas: todo el repositorio se pone de acuerdo en una versión de cada dependencia, y cada crate compilado sirve a todos los miembros. Entender qué comparte y qué no un workspace es entender dónde termina «lo que debe evolucionar junto».
- Montar un workspace con manifiesto virtual y declarar sus miembros.
- Compartir dependencias y metadatos con herencia (
workspace = true). - Comprender por qué un solo
Cargo.locky un solotarget/cambian el juego. - Saber cuándo un workspace ayuda y cuándo estorba.
Anatomía de un workspace
En la raíz colocas un Cargo.toml con la tabla [workspace]. Si esa raíz no tiene su propia sección [package], hablamos de un manifiesto virtual: la raíz no es un crate, solo coordina a los miembros. Es la forma canónica de un monorepo.
[workspace]
resolver = "3"
members = ["crates/*"]
exclude = ["experimentos/prototipo-viejo"]
default-members = ["crates/cli"]
El campo resolver debe fijarse aquí, en la raíz: no se hereda de la edición de los miembros, y en un workspace virtual sin [package] no hay otra edición que lo infiera. En la edición 2024, el resolvedor recomendado es el "3". La estructura en disco deja claro qué es único y qué se multiplica:
mi-monorepo/
├── Cargo.toml # manifiesto virtual: solo [workspace]
├── Cargo.lock # UNO solo, compartido por todos
├── target/ # UNO solo, artefactos compartidos
└── crates/
├── core/ # libreria
├── cli/ # binario, depende de core
└── web/ # binario, depende de core
Herencia: una sola fuente de verdad
La duplicación es el enemigo de un monorepo: si cinco crates fijan serde = "1" por su cuenta, subir de versión es editar cinco ficheros. La herencia lo resuelve. En la raíz declaras una vez las dependencias y los metadatos comunes; los miembros los toman con workspace = true.
[workspace.package]
version = "0.3.0"
edition = "2024"
license = "MIT"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
[workspace.lints.clippy]
unwrap_used = "warn"
[package]
name = "cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
core = { path = "../core" } # dependencia interna por ruta
serde = { workspace = true } # hereda version y features de la raiz
tokio = { workspace = true, features = ["macros"] } # y anade una feature propia
[lints]
workspace = true
Un miembro puede añadir features sobre la dependencia heredada, pero no cambiar su versión: la raíz manda. Así, cargo update -p serde mueve a la vez todo el repositorio, y el conjunto de lints es uniforme sin copiarlo en cada crate.
Un lockfile, un target: qué cambia de verdad
Aquí está el corazón. Un único Cargo.lock significa que el workspace entero resuelve a una sola versión de cada dependencia: las features se unifican a lo ancho del repositorio y todo crate ve el mismo serde con las mismas capacidades. Un único target/ significa que una dependencia se compila una vez y su artefacto alimenta a core, a cli y a web, en lugar de recompilarse por proyecto. En un monorepo grande, ese compartir es la diferencia entre minutos y segundos.
flowchart TD R[Raiz del workspace] --> L[Cargo.lock unico] R --> T[target compartido] R --> M[Miembros] M --> C1[core lib] M --> C2[cli bin] M --> C3[web bin] C2 --> C1 C3 --> C1 style L fill:#89b4fa,color:#11111b style T fill:#a6e3a1,color:#11111b
Las órdenes cotidianas cobran un matiz. Desde la raíz, cargo build compila los default-members (o todo si no los declaraste); --workspace fuerza todos; -p core selecciona uno. Desde dentro de la carpeta de un miembro, Cargo opera sobre ese miembro.
cargo build --workspace # compila todos los miembros
cargo test -p core # prueba solo el crate core
cargo run -p cli # ejecuta el binario cli
cargo tree --workspace # el grafo unificado de todo el repo
Añadir un miembro no exige tocar la raíz si usas un glob: basta crear el crate dentro y el patrón lo recoge solo.
cargo new crates/parser --lib # el patron crates/* lo incluye automaticamente
cargo build -p parser # ya forma parte del workspace y su lockfile
El límite de un workspace es el límite de «lo que debe evolucionar junto». Si agrupas dos herramientas sin relación, las obligas a compartir Cargo.lock y, por tanto, a acordar una única versión de cada dependencia y una única unificación de features que ninguna de las dos pidió. Un workspace es para un producto con partes; no para tu carpeta de proyectos.
Piensa en qué es realmente un workspace y verás que no es una comodidad de organización, sino una decisión sobre los límites de un sistema. Un solo Cargo.lock equivale a un solo universo de versiones: todo el repositorio comparte una y solo una resolución del árbol de dependencias, y por tanto una sola unificación de features. Un solo target/ equivale a una sola caché de artefactos intermedios: cada dependencia compilada es un bien común que ningún miembro recompila. Juntas, estas dos propiedades convierten un puñado de crates en un grafo de compilación único con estructura interna, y esa estructura es precisamente lo que te permite partir un monolito sin pagar el peaje de la duplicación. Fragmentar una librería enorme en core, parser, runtime y cli deja de ser un lujo arquitectónico: cada crate se compila en paralelo, sus fronteras públicas documentan el diseño, y el borrow checker las hace cumplir. Pero la misma unidad que da la fuerza impone la disciplina. Como el lockfile es compartido, dos miembros no pueden discrepar sobre qué versión de una dependencia usan; como las features se unifican, la capacidad que un miembro activa la reciben todos. Por eso el workspace no es un cajón donde tirar proyectos: es una afirmación de que estas piezas forman un solo sistema y deben avanzar acompasadas. Elegir sus fronteras es, en el fondo, decidir qué software es uno solo y qué software son varios.
Un workspace agrupa crates bajo [workspace] en la raíz; sin [package] es virtual. Fija resolver = "3" en la raíz. [workspace.dependencies], [workspace.package] y [workspace.lints] centralizan versiones, metadatos y lints; los miembros los toman con workspace = true y pueden sumar features. Comparten un Cargo.lock (una resolución, features unificadas) y un target/ (artefactos compilados una vez). Úsalo para un producto con partes que evolucionan juntas, no para proyectos ajenos.
- Crea un manifiesto virtual con
members = ["crates/*"]y tres crates:core(lib),cliyweb(bins). Haz quecliywebdependan decoreporpath. - Sube
serdea[workspace.dependencies]y consúmelo conserde.workspace = trueen los tres. Cambia la versión una sola vez y verifica que muta todo el árbol concargo tree. - Centraliza
editionylicenseen[workspace.package]y hereda ambos en cada miembro con la sintaxisedition.workspace = true. - Compara los tiempos de
cargo build -p clidentro del workspace frente a compilarclicomo proyecto aislado; explica el papel deltarget/compartido. - Añade
[workspace.lints.clippy]conunwrap_used = "warn", actívalo en un miembro con[lints] workspace = truey provoca el aviso con un.unwrap().