PRODSovereign European BaaS platformOpen Dashboard →

Engineering · 10 min read

Cargo workspaces for a multi-service Rust backend

Affane Daylami · Fondateur · August 12, 2026

Back to blog

A multi-service backend in Rust quickly raises the same question: one depot per service, or a single Cargo workspace? Aurabase decided for the second option. Eleven services, a CLI, and five shared libraries live in a single Cargo.toml root, with a single Cargo.lock for everyone. Here is how this workspace is constructed, reading it line by line.

This English text was generated automatically from the French original and has not been reviewed yet.

This is not an educational example invented for the occasion. Each code snippet below comes from the Aurabase root Cargo.toml and its crate manifests, as they exist in the repository today — including two discrepancies that we found while re-reading them for this article, and which we are documenting as is rather than quietly fixing them before publication.

The essentials
  • The Aurabase root Cargo workspace declares 18 explicit members: 11 services, the aura-cliCLI, 5 shared libs and the aurabase-rs SDK — under resolver = "2" and a single Cargo.lock.
  • [workspace.dependencies] centralizes shared versions; each crate inherits with { workspace = true } rather than setting its own number — except when a crate overrides, silently.
  • A folder under services/ is not automatically a member of the workspace: the members list is explicit, not a glob, precisely to be able to exclude non-Rust code.
  • [profile.release] applies only once to the entire workspace — a choice like panic = "unwind" then applies to all eleven services at once.
#
Why a workspace

One depot per service, or just one Cargo.lock?

A workspace Cargo groups multiple crates under a single Cargo.lock and a single target directory — the very functionality that Rust's package manager provides for this case. Eleven separate Rust binaries, each in its own repository, seem more independent at first glance. In practice, this means eleven distinct Cargo.lock, eleven version resolutions that may diverge over time, and no guarantee that two services compile the same version ofaxum or sqlx.

A Cargo workspace solves this at the package manager level, not the team discipline level. All members share a single Cargo.lock at the root: a common dependency is resolved once, to an identical version, for the entire graph. This is also what makes a cross-service refactor (changing a signature in aura-core, for example) visible to a single cargo build --workspace, rather than discovered service by service in production. This is one of the choices that distinguishes our 100% unified Rust core from a heterogeneous stack assembled service by service.

#
Anatomy

The Cargo.toml root: resolver and members

It all starts with the declaration [workspace] and its list members. At Aurabase, this list is handwritten, grouped by role — services, tools, libs — rather than generated by a generic pattern like services/*.

Cargo.tomltoml
[workspace]
resolver = "2"

members = [
    # Services
    "services/aura-gateway",
    "services/aura-auth",
    "services/aura-db",
    # … 8 other services (aura-realtime, aura-storage, aura-ai, …)
    # Tools
    "aura-cli",
    # Libs
    "libs/aura-core",
    # …4 other libs
    "aurabase-rs",
]

resolver = "2" is not a cosmetic detail. Cargo's resolver v2 isolates the functionality of build-dependencies and target-specific dependencies (target.'cfg(windows)', for example) from the rest of the graph — they no longer leak into the final binary. It also unifies the versions of the same dependency across all members of the workspace who share it: a single axum, only once, not eleven independent resolutions.

#
Legacy

workspace.package: one version, one edition, in principle shared

[workspace.package] once declares the common fields — version, edition, authors, license — that each crate can inherit with version.workspace = true instead of copying them.

Cargo.toml (root)toml
[workspace.package]
version = "0.1.1"
edition = "2021"

# services/aura-gateway/Cargo.toml
[package]
name = "aura-gateway"
version.workspace = true

Aurabase's eleven services all follow this pattern. Two crates deviate, and this is the first of the two discrepancies found while preparing this article: aura-cli declares version = "0.2.0" in hard copy, and the lib libs/aura-migrations declares version = "0.1.0" — both different from the 0.1.1 of the workspace.

What it means

version.workspace = true is optional, field by field, crate by crate. Nothing prevents a crate from keeping its own numbering — voluntarily (a tool published separately, for example) or by forgetfulness. A workspace audit should check this field crate by crate, not assume inheritance.

#
Deduplication

workspace.dependencies: a source of truth, unless a crate bypasses it

[workspace.dependencies] centralizes dependencies shared by several crates. Each service refers to it with { workspace = true } instead of setting its own version constraint.

Cargo.toml (root)toml
[workspace.dependencies]
axum = { version = "0.8", features = ["ws", "multipart", "macros"] }
sqlx = { version = "0.8", default-features = false, features = […] }
tower_governor = { version = "0.8", features = ["axum"] }
governor = "0.10"

# services/aura-gateway/Cargo.toml — does NOT inherit the workspace governor
governor = "0.8"  # local version, different

This is the second real discrepancy: the workspace centralizes governor in version 0.10, but aura-gateway redeclares its own line governor = "0.8" instead of inheriting — the gateway applies its rate limiting with the bare governor crate, when aura-auth, aura-ai, aura-db and aura-functions pass through tower_governor in middleware. A workspace does not prevent divergence: it only makes it visible, if we take the trouble to compare.

Not all dependencies deserve to be centralized, however. wasmtime does not appear anywhere in [workspace.dependencies]: only one crate, aura-functions, uses it for its WASM runtime, so it remains declared locally. The rule we apply: raise a dependency to the workspace level from the moment two or more crates share it, not before.

#
Internal graph

libs/ to services/: which depends on what

The five shared libs (aura-core, aura-crypto, aura-db-adapters, aura-migrations, aura-telemetry) are declared as path dependencies in [workspace.dependencies] — aura-core = { path = "libs/aura-core" } — then each service chooses which ones it actually needs.

The resulting graph remains readable from one file to another. aura-gateway only depends on three of the five internal libs — aura-core, aura-crypto and aura-telemetry. Enough to route, sign a JWT for the PostgREST sidecar and export its traces, without touching aura-db-adapters nor aura-migrations. aura-provisioneradds aura-migrations: it replays the schema migrations resulting from the creation of a project.

The narrowest case is aura-migrator, the dedicated binary that performs CI/CD migrations. Among Aurabase internal libs, its production [dependencies] only listsaura-migrations — aura-core only appears in its [dev-dependencies], for testing. The binary delivered to production therefore does not include anyaura-core code; it only exists during cargo test.

#
Concrete case

One crate, several binaries: aura-realtime division

A workspace does not require creating a new member each time you want a new deployable process. aura-realtime remains a single member of the workspace, but its Cargo.toml declares three distinct [[bin]] tables around the same shared [lib].

Cargo.tomltoml
[lib]
name = "aura_realtime"

[[bin]]
name = "aura-realtime"

[[bin]]
name = "aura-realtime-cdc-worker"
path = "src/bin/cdc_worker.rs"

[[bin]]
name = "aura-realtime-ws-front"
path = "src/bin/ws_front.rs"

The manifesto itself documents the boundary between the two. cdc-worker captures Postgres changes by polling and publishes to NATS in pub/sub core (Client::publish_with_headers), without ever opening a WebSocket server — JetStream, in this same service, exclusively serves the cross-instance presence KV, not the CDC fan-out. ws-front only consumes NATS and holds WebSocket/SSE connections, without ever touching the CDC — the full details are in thereal-time engine documentation. Both binaries share the same RLS filtering code via [lib], but deploy and scale independently in Kubernetes. This is the right signal to choose several binaries in a crate rather than a new workspace member: same internal logic, different deployment topologies.

#
Trap to avoid

A file under services/ is not necessarily a Cargo member

The aura-edge-runtime folder does exist in the Aurabase repository. However, it does not contain any Cargo.toml — only TypeScript (index.ts, envelope.ts), and it does not appear anywhere in the members list of the root workspace.

Astuce

This is exactly why Aurabase's members list is handwritten rather than replaced by a generic pattern like services/*. A glob would have attempted to include this non-Rust folder in the workspace, with a resolution failure as a result. An explicit list allows folders that do not speak the same language to coexist, under the same parent services/.

The lesson generalizes: counting the services of a Rust backend by listing the subfolders of services/ gives a false number. Only the root Cargo.toml is authoritative on what actually compiles in the workspace.

#
Compilation

[profile.release]: a single setting for the entire workspace

A [profile.release] declared at the root of the workspace applies to all members compiled in release mode — only one place to adjust, not eleven. Cargo documents all available keys in its compilation profile reference; Aurabase only activates five.

Cargo.toml (root)toml
[profile.release]
lto = "thin"          # LTO cross-crates, performance/build time balance
codegen-units = 1     # best overall inlining
panic = "unwind"   # a panic isolated by tokio/axum → 500, not a global crash
strip = "symbols"
opt-level = 3

The choice of panic = "unwind" rather than "abort" is documented directly in the file comment: a panic in an axum handler is intercepted by tokio, the task returns a 500, and the process continues to serve other concurrent requests. The performance gain ofabort is not worth the loss of inter-query isolation.

#
In practice

Freeze the toolchain, and the commands that matter

A unified workspace sets dependency versions, but not the version of the compiler itself. rust-toolchain.toml, at the root, freezes the toolchain (channel = "1.93" today) for any direct call to cargo or rustc in the repository.

This file exists precisely because a silent drift occurred: a CI action installed the stable channel of the day without reading rust-toolchain.toml, while the release Docker image compiled with the frozen version. A commit could pass all the tests with today's stable Rust, then break at the image build — discovered after the merge, not before. rustuprespects this file for any direct command: it was the only necessary fix, without affecting CI workflows.

terminalbash
# Compile the entire workspace
cargo build --workspace

# Test a single crate (not the entire workspace)
cargo test -p aura-auth

# Strict lint on the entire workspace, warnings = errors
cargo clippy --workspace --all-targets -- -D warnings

# Formatting
cargo fmt --all

One last detail, for those who read a manifest before executing it: the aura-cli crate compiles a binary whose table [[bin]] names it aurabase, not aura. The published npm wrapper, @aurabase/cli, exposes the two commands — aura and aurabase both point to the same script. The name of a [[bin]] Cargo, the name of the crate, and the name exposed by an npm wrapper are three separate things; none can be guessed from the other two.

#
Recap

Checklist: where to declare what

Five decisions come up each time you add a crate to a Cargo workspace. Here is where each is declared, based on the Aurabase example covered above.

List of members[workspace] membersRoot — explicit list, never a glob
Shared version/edition[workspace.package]Root — version.workspace = true, by crate, optional
Dependency shared by 2+ crates[workspace.dependencies]Root — then { workspace = true } in each crate
Dependence on a single consumer[dependencies] of the crateLocally, without going through the workspace
Build profile[profile.release]Root only — applies to all members

To audit an existing Cargo workspace — yours or that of a project you are taking over — four checks are enough to find the kind of discrepancies documented above:

  1. Compare [workspace.package].version to the version of each crate — a different value is not necessarily a bug, but is worth documenting.
  2. Compare [workspace.dependencies] to the dependencies declared locally by each crate — spot the names present on both sides with different versions.
  3. Compare the members list in the root Cargo.toml to the actual subfolders in the repository — a folder missing from members is not necessarily an oversight.
  4. Check the actual name of each compiled binary ([[bin]] name) rather than assuming it matches the crate name or the name exposed by a possible npm wrapper.

READY TO DEPLOY?

Your backend in five minutes.

No credit card required · 500 MB free · 50,000 MAU