monorepo.
monorepo7 min read

Cargo + uv + Bun in One Repo: Shared Schema Mirrors That Don't Drift

A pragmatic layout for running Cargo, uv, and Bun side by side in one polyglot monorepo, with a single source of truth for types that mirrors cleanly into Rust, Python, and TypeScript.

cargo uv bun monorepopolyglot monoreposhared types rust python typescriptjson schema codegenworkspace tooling

Cargo + uv + Bun in One Repo: Shared Schema Mirrors That Don't Drift

Three workspace managers in one repo sounds like a recipe for tooling chaos. In practice, Cargo, uv, and Bun stay out of each other's way as long as you give each one a real workspace root and a clear lane for shared contracts. The hard part is not the file layout — it's keeping the type definitions on the Rust, Python, and TypeScript sides from drifting the moment someone adds a field on one side and forgets the other two.

This is the layout I land on after enough drift incidents to take the problem seriously: one schema source of truth, generated mirrors per language, and a CI gate that fails when the mirrors fall out of sync.

Why one repo at all

Splitting per-language repos is the obvious move, and for a team of twenty it is probably the right one. For a small team or a solo operator running a daemon in Rust, an orchestrator in Python, and a desktop or web shell in TypeScript, the per-repo overhead is brutal: three CI configs, three issue trackers, three release cadences, and a permanent context-switch tax every time a contract changes.

A polyglot monorepo collapses that. You get one PR for a cross-cutting change, one diff to review, and atomic commits when the wire format shifts. The cost is tooling discipline — which is what the rest of this article is about.

Workspace roots, side by side

Each ecosystem expects to own the repo root. The trick is letting each one think it does, by putting their manifests at the top level and letting them ignore each other's directories.

your_project/
  Cargo.toml          # [workspace] members = ["crates/*", "apps/*-rs"]
  pyproject.toml      # [tool.uv.workspace] members = ["services/*", "libs/py-*"]
  package.json        # workspaces: ["apps/*-ts", "libs/ts-*"]
  schemas/            # source of truth (JSON Schema or Protobuf)
  crates/
  services/
  apps/
  libs/

Cargo, uv, and Bun each have a workspace concept. Cargo's workspace docs describe members globs that pull crates into a shared target/ and lockfile. uv's workspace support follows the same model for Python projects, sharing one uv.lock across all members. Bun's workspaces read package.json workspaces globs and hoist a single node_modules and bun.lock.

None of the three care that the others exist, as long as you keep their member globs disjoint. The only collision risk is node_modules/ and Cargo's target/ showing up where Python tooling tries to walk — solve that with .gitignore plus a tool.ruff.exclude and tool.mypy.exclude block in pyproject.toml.

The schema-mirror problem

Once the workspaces coexist, the next failure mode shows up: a Python service emits a JSON payload, the Rust daemon deserializes it, and the TypeScript client renders it. Three language type systems, one wire format, and zero compile-time guarantee that they agree.

The naïve fix is to write the type by hand in each language. This works for about two weeks. Then someone renames a field in Python, forgets to update the Rust struct, and the daemon starts silently dropping events because serde does not error on missing fields by default. The drift is invisible until production.

The real fix is a single source of truth that generates the mirrors.

Source of truth: JSON Schema or Protobuf

Both work. Pick based on whether you want a wire format opinion baked in.

JSON Schema is the lighter-weight choice when your wire format is already JSON over HTTP or WebSocket. Define the contract once, generate Rust with typify, Python with datamodel-code-generator, and TypeScript with json-schema-to-typescript. All three are mature, all three respect the same draft-2020-12 spec, and all three produce idiomatic output (serde-friendly Rust, Pydantic v2 Python, plain TS interfaces).

Protobuf is the right call if you want gRPC, a binary wire format, or stricter cross-language guarantees. The codegen story is more uniform — protoc plugins for every language — but the operational tax is higher: you need a .proto toolchain in CI, and Pydantic-style validation lives outside the generated code.

For a small polyglot stack with HTTP + WS as the only transports, JSON Schema wins on simplicity. The codegen step is fast (under a second per language for a few dozen schemas), the diff is human-readable, and you can hand-edit a schema in any text editor.

A concrete codegen pipeline

Here is the pattern that has held up across several iterations.

#!/usr/bin/env bash
# scripts/gen-schemas.sh — run on every contract change, gated in CI
set -euo pipefail

SCHEMA_DIR="schemas"
RS_OUT="crates/shared-types/src/generated.rs"
PY_OUT="libs/py-shared/py_shared/generated.py"
TS_OUT="libs/ts-shared/src/generated.ts"

# Rust: typify reads JSON Schema, emits serde-derived structs
cargo run --quiet -p schema-codegen -- "$SCHEMA_DIR" > "$RS_OUT"

# Python: datamodel-code-generator emits Pydantic v2 models
uv run datamodel-codegen \
  --input "$SCHEMA_DIR" \
  --input-file-type jsonschema \
  --output-model-type pydantic_v2.BaseModel \
  --output "$PY_OUT"

# TypeScript: json-schema-to-typescript emits interfaces
bun x json-schema-to-typescript "$SCHEMA_DIR/*.json" > "$TS_OUT"

# Format each output with the language's canonical formatter
cargo fmt -p shared-types
uv run ruff format "$PY_OUT"
bun x prettier --write "$TS_OUT"

Each generated file gets a header comment marking it as machine-generated, and .gitattributes flags them as linguist-generated=true so they don't pollute PR review diffs.

The CI gate that catches drift

Generating mirrors is half the answer. The other half is making sure nobody ships a schema change without regenerating. The check is two lines:

./scripts/gen-schemas.sh
git diff --exit-code crates/shared-types libs/py-shared libs/ts-shared

If the codegen produces any change, the diff is non-empty and CI fails. The contributor regenerates locally, commits the result, and the PR proceeds. This single gate has caught more drift than every code review combined.

The trade-off versus a runtime contract test (e.g. a Pydantic validator round-tripping every Rust serialization) is speed: the diff check runs in under a second on a small schema set. A round-trip test runs in tens of seconds and only catches drift after the schema is already merged. Use both if the contract surface is large; the diff check alone covers ~80% of real incidents at zero runtime cost.

Workspace lockfiles, one per ecosystem

Resist the urge to commit a unified lockfile or a meta-lockfile. Each ecosystem has its own opinion and its own resolver:

  • Cargo.lock at the repo root, covering every workspace crate
  • uv.lock at the repo root, covering every uv workspace member
  • bun.lock at the repo root, covering every Bun workspace package

Three lockfiles, three resolvers, zero conflicts. The only meta-coordination needed is a Makefile or justfile that wraps cargo build && uv sync && bun install so a fresh clone bootstraps in one command.

When this layout breaks down

The pattern holds well up to roughly a dozen schemas and a half-dozen services per language. Past that, two issues show up:

  1. Codegen latency — running all three generators on every CI job adds 10-30 seconds. Cache the generated files keyed on the schema directory hash; only regenerate when schemas change.
  2. Cross-cutting refactors — renaming a field in 40 schemas at once is painful even with codegen. At that scale, invest in a schema-migration tool (or write one — it is a 200-line script) that batches the rename + regen + downstream find-replace.

If you hit either wall, you have outgrown the small-team layout. Splitting the schema repo out as its own package with versioned releases is the next step, and it preserves the codegen pattern while letting each language consume contracts at its own cadence.

What you actually get from this

A polyglot monorepo with shared schema mirrors gives you three things that per-repo splits cannot match: atomic cross-language refactors, a single source of truth for the wire format, and a CI gate that catches drift before merge instead of after deploy. The cost is a few hundred lines of codegen plumbing and the discipline to never hand-edit a generated file.

Cargo, uv, and Bun coexist quietly because none of them try to own the others' files. The schema layer is what turns three coexisting workspaces into one coherent system.

References:

  • https://doc.rust-lang.org/cargo/reference/workspaces.html
  • https://docs.astral.sh/uv/concepts/projects/workspaces/
  • https://bun.sh/docs/install/workspaces
  • https://github.com/oxidecomputer/typify
  • https://github.com/koxudaxi/datamodel-code-generator
  • https://github.com/bcherny/json-schema-to-typescript